diff --git a/src/applications/differential/controller/DifferentialChangesetViewController.php b/src/applications/differential/controller/DifferentialChangesetViewController.php
index fe6163feb9..f2558ffa1d 100644
--- a/src/applications/differential/controller/DifferentialChangesetViewController.php
+++ b/src/applications/differential/controller/DifferentialChangesetViewController.php
@@ -1,464 +1,465 @@
 <?php
 
 final class DifferentialChangesetViewController extends DifferentialController {
 
   public function shouldAllowPublic() {
     return true;
   }
 
   public function handleRequest(AphrontRequest $request) {
     $viewer = $this->getViewer();
 
     $rendering_reference = $request->getStr('ref');
     $parts = explode('/', $rendering_reference);
     if (count($parts) == 2) {
       list($id, $vs) = $parts;
     } else {
       $id = $parts[0];
       $vs = 0;
     }
 
     $id = (int)$id;
     $vs = (int)$vs;
 
     $load_ids = array($id);
     if ($vs && ($vs != -1)) {
       $load_ids[] = $vs;
     }
 
     $changesets = id(new DifferentialChangesetQuery())
       ->setViewer($viewer)
       ->withIDs($load_ids)
       ->needHunks(true)
       ->execute();
     $changesets = mpull($changesets, null, 'getID');
 
     $changeset = idx($changesets, $id);
     if (!$changeset) {
       return new Aphront404Response();
     }
 
     $vs_changeset = null;
     if ($vs && ($vs != -1)) {
       $vs_changeset = idx($changesets, $vs);
       if (!$vs_changeset) {
         return new Aphront404Response();
       }
     }
 
     $view = $request->getStr('view');
     if ($view) {
       $phid = idx($changeset->getMetadata(), "$view:binary-phid");
       if ($phid) {
         return id(new AphrontRedirectResponse())->setURI("/file/info/$phid/");
       }
       switch ($view) {
         case 'new':
           return $this->buildRawFileResponse($changeset, $is_new = true);
         case 'old':
           if ($vs_changeset) {
             return $this->buildRawFileResponse($vs_changeset, $is_new = true);
           }
           return $this->buildRawFileResponse($changeset, $is_new = false);
         default:
           return new Aphront400Response();
       }
     }
 
     $old = array();
     $new = array();
     if (!$vs) {
       $right = $changeset;
       $left  = null;
 
       $right_source = $right->getID();
       $right_new = true;
       $left_source = $right->getID();
       $left_new = false;
 
       $render_cache_key = $right->getID();
 
       $old[] = $changeset;
       $new[] = $changeset;
     } else if ($vs == -1) {
       $right = null;
       $left = $changeset;
 
       $right_source = $left->getID();
       $right_new = false;
       $left_source = $left->getID();
       $left_new = true;
 
       $render_cache_key = null;
 
       $old[] = $changeset;
       $new[] = $changeset;
     } else {
       $right = $changeset;
       $left = $vs_changeset;
 
       $right_source = $right->getID();
       $right_new = true;
       $left_source = $left->getID();
       $left_new = true;
 
       $render_cache_key = null;
 
       $new[] = $left;
       $new[] = $right;
     }
 
     if ($left) {
       $left_data = $left->makeNewFile();
       $left_properties = $left->getNewProperties();
       if ($right) {
         $right_data = $right->makeNewFile();
         $right_properties = $right->getNewProperties();
       } else {
         $right_data = $left->makeOldFile();
         $right_properties = $left->getOldProperties();
       }
 
       $engine = new PhabricatorDifferenceEngine();
       $synthetic = $engine->generateChangesetFromFileContent(
         $left_data,
         $right_data);
 
       $choice = clone nonempty($left, $right);
       $choice->attachHunks($synthetic->getHunks());
 
       $choice->setOldProperties($left_properties);
       $choice->setNewProperties($right_properties);
 
       $changeset = $choice;
     }
 
     if ($left_new || $right_new) {
       $diff_map = array();
       if ($left) {
         $diff_map[] = $left->getDiff();
       }
       if ($right) {
         $diff_map[] = $right->getDiff();
       }
       $diff_map = mpull($diff_map, null, 'getPHID');
 
       $buildables = id(new HarbormasterBuildableQuery())
         ->setViewer($viewer)
         ->withBuildablePHIDs(array_keys($diff_map))
         ->withManualBuildables(false)
         ->needBuilds(true)
         ->needTargets(true)
         ->execute();
       $buildables = mpull($buildables, null, 'getBuildablePHID');
       foreach ($diff_map as $diff_phid => $changeset_diff) {
         $changeset_diff->attachBuildable(idx($buildables, $diff_phid));
       }
     }
 
     $coverage = null;
     if ($right_new) {
       $coverage = $this->loadCoverage($right);
     }
 
     $spec = $request->getStr('range');
     list($range_s, $range_e, $mask) =
       DifferentialChangesetParser::parseRangeSpecification($spec);
 
     $parser = id(new DifferentialChangesetParser())
+      ->setViewer($viewer)
       ->setCoverage($coverage)
       ->setChangeset($changeset)
       ->setRenderingReference($rendering_reference)
       ->setRenderCacheKey($render_cache_key)
       ->setRightSideCommentMapping($right_source, $right_new)
       ->setLeftSideCommentMapping($left_source, $left_new);
 
     $parser->readParametersFromRequest($request);
 
     if ($left && $right) {
       $parser->setOriginals($left, $right);
     }
 
     $diff = $changeset->getDiff();
     $revision_id = $diff->getRevisionID();
 
     $can_mark = false;
     $object_owner_phid = null;
     $revision = null;
     if ($revision_id) {
       $revision = id(new DifferentialRevisionQuery())
         ->setViewer($viewer)
         ->withIDs(array($revision_id))
         ->executeOne();
       if ($revision) {
         $can_mark = ($revision->getAuthorPHID() == $viewer->getPHID());
         $object_owner_phid = $revision->getAuthorPHID();
       }
     }
 
     // Load both left-side and right-side inline comments.
     if ($revision) {
       $query = id(new DifferentialInlineCommentQuery())
         ->setViewer($viewer)
         ->needHidden(true)
         ->withRevisionPHIDs(array($revision->getPHID()));
       $inlines = $query->execute();
       $inlines = $query->adjustInlinesForChangesets(
         $inlines,
         $old,
         $new,
         $revision);
     } else {
       $inlines = array();
     }
 
     if ($left_new) {
       $inlines = array_merge(
         $inlines,
         $this->buildLintInlineComments($left));
     }
 
     if ($right_new) {
       $inlines = array_merge(
         $inlines,
         $this->buildLintInlineComments($right));
     }
 
     $phids = array();
     foreach ($inlines as $inline) {
       $parser->parseInlineComment($inline);
       if ($inline->getAuthorPHID()) {
         $phids[$inline->getAuthorPHID()] = true;
       }
     }
     $phids = array_keys($phids);
 
     $handles = $this->loadViewerHandles($phids);
     $parser->setHandles($handles);
 
     $engine = new PhabricatorMarkupEngine();
     $engine->setViewer($viewer);
 
     foreach ($inlines as $inline) {
       $engine->addObject(
         $inline,
         PhabricatorInlineCommentInterface::MARKUP_FIELD_BODY);
     }
 
     $engine->process();
 
     $parser
       ->setUser($viewer)
       ->setMarkupEngine($engine)
       ->setShowEditAndReplyLinks(true)
       ->setCanMarkDone($can_mark)
       ->setObjectOwnerPHID($object_owner_phid)
       ->setRange($range_s, $range_e)
       ->setMask($mask);
 
     if ($request->isAjax()) {
       // NOTE: We must render the changeset before we render coverage
       // information, since it builds some caches.
       $rendered_changeset = $parser->renderChangeset();
 
       $mcov = $parser->renderModifiedCoverage();
 
       $coverage_data = array(
         'differential-mcoverage-'.md5($changeset->getFilename()) => $mcov,
       );
 
       return id(new PhabricatorChangesetResponse())
         ->setRenderedChangeset($rendered_changeset)
         ->setCoverage($coverage_data)
         ->setUndoTemplates($parser->getRenderer()->renderUndoTemplates());
     }
 
     $detail = id(new DifferentialChangesetListView())
       ->setUser($this->getViewer())
       ->setChangesets(array($changeset))
       ->setVisibleChangesets(array($changeset))
       ->setRenderingReferences(array($rendering_reference))
       ->setRenderURI('/differential/changeset/')
       ->setDiff($diff)
       ->setTitle(pht('Standalone View'))
       ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
       ->setIsStandalone(true)
       ->setParser($parser);
 
     if ($revision_id) {
       $detail->setInlineCommentControllerURI(
         '/differential/comment/inline/edit/'.$revision_id.'/');
     }
 
     $crumbs = $this->buildApplicationCrumbs();
 
     if ($revision_id) {
       $crumbs->addTextCrumb('D'.$revision_id, '/D'.$revision_id);
     }
 
     $diff_id = $diff->getID();
     if ($diff_id) {
       $crumbs->addTextCrumb(
         pht('Diff %d', $diff_id),
         $this->getApplicationURI('diff/'.$diff_id));
     }
 
     $crumbs->addTextCrumb($changeset->getDisplayFilename());
     $crumbs->setBorder(true);
 
     $header = id(new PHUIHeaderView())
       ->setHeader(pht('Changeset View'))
       ->setHeaderIcon('fa-gear');
 
     $view = id(new PHUITwoColumnView())
       ->setHeader($header)
       ->setFooter($detail);
 
     return $this->newPage()
       ->setTitle(pht('Changeset View'))
       ->setCrumbs($crumbs)
       ->appendChild($view);
   }
 
   private function buildRawFileResponse(
     DifferentialChangeset $changeset,
     $is_new) {
 
     $viewer = $this->getViewer();
 
     if ($is_new) {
       $key = 'raw:new:phid';
     } else {
       $key = 'raw:old:phid';
     }
 
     $metadata = $changeset->getMetadata();
 
     $file = null;
     $phid = idx($metadata, $key);
     if ($phid) {
       $file = id(new PhabricatorFileQuery())
         ->setViewer($viewer)
         ->withPHIDs(array($phid))
         ->execute();
       if ($file) {
         $file = head($file);
       }
     }
 
     if (!$file) {
       // This is just building a cache of the changeset content in the file
       // tool, and is safe to run on a read pathway.
       $unguard = AphrontWriteGuard::beginScopedUnguardedWrites();
 
       if ($is_new) {
         $data = $changeset->makeNewFile();
       } else {
         $data = $changeset->makeOldFile();
       }
 
       $diff = $changeset->getDiff();
 
       $file = PhabricatorFile::newFromFileData(
         $data,
         array(
           'name' => $changeset->getFilename(),
           'mime-type' => 'text/plain',
           'ttl.relative' => phutil_units('24 hours in seconds'),
           'viewPolicy' => PhabricatorPolicies::POLICY_NOONE,
         ));
 
       $file->attachToObject($diff->getPHID());
 
       $metadata[$key] = $file->getPHID();
       $changeset->setMetadata($metadata);
       $changeset->save();
 
       unset($unguard);
     }
 
     return $file->getRedirectResponse();
   }
 
   private function buildLintInlineComments($changeset) {
     $diff = $changeset->getDiff();
 
     $target_phids = $diff->getBuildTargetPHIDs();
     if (!$target_phids) {
       return array();
     }
 
     $messages = id(new HarbormasterBuildLintMessage())->loadAllWhere(
       'buildTargetPHID IN (%Ls) AND path = %s',
       $target_phids,
       $changeset->getFilename());
 
     if (!$messages) {
       return array();
     }
 
     $change_type = $changeset->getChangeType();
     if (DifferentialChangeType::isDeleteChangeType($change_type)) {
       // If this is a lint message on a deleted file, show it on the left
       // side of the UI because there are no source code lines on the right
       // side of the UI so inlines don't have anywhere to render. See PHI416.
       $is_new = 0;
     } else {
       $is_new = 1;
     }
 
     $template = id(new DifferentialInlineComment())
       ->setChangesetID($changeset->getID())
       ->setIsNewFile($is_new)
       ->setLineLength(0);
 
     $inlines = array();
     foreach ($messages as $message) {
       $description = $message->getProperty('description');
 
       $inlines[] = id(clone $template)
         ->setSyntheticAuthor(pht('Lint: %s', $message->getName()))
         ->setLineNumber($message->getLine())
         ->setContent($description);
     }
 
     return $inlines;
   }
 
   private function loadCoverage(DifferentialChangeset $changeset) {
     $viewer = $this->getViewer();
 
     $target_phids = $changeset->getDiff()->getBuildTargetPHIDs();
     if (!$target_phids) {
       return null;
     }
 
     $unit = id(new HarbormasterBuildUnitMessageQuery())
       ->setViewer($viewer)
       ->withBuildTargetPHIDs($target_phids)
       ->execute();
     if (!$unit) {
       return null;
     }
 
     $coverage = array();
     foreach ($unit as $message) {
       $test_coverage = $message->getProperty('coverage');
       if ($test_coverage === null) {
         continue;
       }
       $coverage_data = idx($test_coverage, $changeset->getFileName());
       if (!strlen($coverage_data)) {
         continue;
       }
       $coverage[] = $coverage_data;
     }
 
     if (!$coverage) {
       return null;
     }
 
     return ArcanistUnitTestResult::mergeCoverage($coverage);
   }
 
 }
diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php
index 9843ff3d5d..058fc9c766 100644
--- a/src/applications/differential/parser/DifferentialChangesetParser.php
+++ b/src/applications/differential/parser/DifferentialChangesetParser.php
@@ -1,1678 +1,1746 @@
 <?php
 
 final class DifferentialChangesetParser extends Phobject {
 
   const HIGHLIGHT_BYTE_LIMIT = 262144;
 
   protected $visible      = array();
   protected $new          = array();
   protected $old          = array();
   protected $intra        = array();
   protected $depthOnlyLines = array();
   protected $newRender    = null;
   protected $oldRender    = null;
 
   protected $filename     = null;
   protected $hunkStartLines = array();
 
   protected $comments     = array();
   protected $specialAttributes = array();
 
   protected $changeset;
 
   protected $renderCacheKey = null;
 
   private $handles = array();
   private $user;
 
   private $leftSideChangesetID;
   private $leftSideAttachesToNewFile;
 
   private $rightSideChangesetID;
   private $rightSideAttachesToNewFile;
 
   private $originalLeft;
   private $originalRight;
 
   private $renderingReference;
   private $isSubparser;
 
   private $isTopLevel;
 
   private $coverage;
   private $markupEngine;
   private $highlightErrors;
   private $disableCache;
   private $renderer;
   private $characterEncoding;
   private $highlightAs;
   private $highlightingDisabled;
   private $showEditAndReplyLinks = true;
   private $canMarkDone;
   private $objectOwnerPHID;
   private $offsetMode;
 
   private $rangeStart;
   private $rangeEnd;
   private $mask;
   private $linesOfContext = 8;
 
   private $highlightEngine;
+  private $viewer;
 
   public function setRange($start, $end) {
     $this->rangeStart = $start;
     $this->rangeEnd = $end;
     return $this;
   }
 
   public function setMask(array $mask) {
     $this->mask = $mask;
     return $this;
   }
 
   public function renderChangeset() {
     return $this->render($this->rangeStart, $this->rangeEnd, $this->mask);
   }
 
   public function setShowEditAndReplyLinks($bool) {
     $this->showEditAndReplyLinks = $bool;
     return $this;
   }
 
   public function getShowEditAndReplyLinks() {
     return $this->showEditAndReplyLinks;
   }
 
   public function setHighlightAs($highlight_as) {
     $this->highlightAs = $highlight_as;
     return $this;
   }
 
   public function getHighlightAs() {
     return $this->highlightAs;
   }
 
   public function setCharacterEncoding($character_encoding) {
     $this->characterEncoding = $character_encoding;
     return $this;
   }
 
   public function getCharacterEncoding() {
     return $this->characterEncoding;
   }
 
   public function setRenderer(DifferentialChangesetRenderer $renderer) {
     $this->renderer = $renderer;
     return $this;
   }
 
   public function getRenderer() {
     if (!$this->renderer) {
       return new DifferentialChangesetTwoUpRenderer();
     }
     return $this->renderer;
   }
 
   public function setDisableCache($disable_cache) {
     $this->disableCache = $disable_cache;
     return $this;
   }
 
   public function getDisableCache() {
     return $this->disableCache;
   }
 
   public function setCanMarkDone($can_mark_done) {
     $this->canMarkDone = $can_mark_done;
     return $this;
   }
 
   public function getCanMarkDone() {
     return $this->canMarkDone;
   }
 
   public function setObjectOwnerPHID($phid) {
     $this->objectOwnerPHID = $phid;
     return $this;
   }
 
   public function getObjectOwnerPHID() {
     return $this->objectOwnerPHID;
   }
 
   public function setOffsetMode($offset_mode) {
     $this->offsetMode = $offset_mode;
     return $this;
   }
 
   public function getOffsetMode() {
     return $this->offsetMode;
   }
 
+  public function setViewer(PhabricatorUser $viewer) {
+    $this->viewer = $viewer;
+    return $this;
+  }
+
+  public function getViewer() {
+    return $this->viewer;
+  }
+
   public static function getDefaultRendererForViewer(PhabricatorUser $viewer) {
     $is_unified = $viewer->compareUserSetting(
       PhabricatorUnifiedDiffsSetting::SETTINGKEY,
       PhabricatorUnifiedDiffsSetting::VALUE_ALWAYS_UNIFIED);
 
     if ($is_unified) {
       return '1up';
     }
 
     return null;
   }
 
   public function readParametersFromRequest(AphrontRequest $request) {
     $this->setCharacterEncoding($request->getStr('encoding'));
     $this->setHighlightAs($request->getStr('highlight'));
 
     $renderer = null;
 
     // If the viewer prefers unified diffs, always set the renderer to unified.
     // Otherwise, we leave it unspecified and the client will choose a
     // renderer based on the screen size.
 
     if ($request->getStr('renderer')) {
       $renderer = $request->getStr('renderer');
     } else {
       $renderer = self::getDefaultRendererForViewer($request->getViewer());
     }
 
     switch ($renderer) {
       case '1up':
         $this->setRenderer(new DifferentialChangesetOneUpRenderer());
         break;
       default:
         $this->setRenderer(new DifferentialChangesetTwoUpRenderer());
         break;
     }
 
     return $this;
   }
 
   const CACHE_VERSION = 14;
   const CACHE_MAX_SIZE = 8e6;
 
   const ATTR_GENERATED  = 'attr:generated';
   const ATTR_DELETED    = 'attr:deleted';
   const ATTR_UNCHANGED  = 'attr:unchanged';
   const ATTR_MOVEAWAY   = 'attr:moveaway';
 
   public function setOldLines(array $lines) {
     $this->old = $lines;
     return $this;
   }
 
   public function setNewLines(array $lines) {
     $this->new = $lines;
     return $this;
   }
 
   public function setSpecialAttributes(array $attributes) {
     $this->specialAttributes = $attributes;
     return $this;
   }
 
   public function setIntraLineDiffs(array $diffs) {
     $this->intra = $diffs;
     return $this;
   }
 
   public function setDepthOnlyLines(array $lines) {
     $this->depthOnlyLines = $lines;
     return $this;
   }
 
   public function getDepthOnlyLines() {
     return $this->depthOnlyLines;
   }
 
   public function setVisibileLinesMask(array $mask) {
     $this->visible = $mask;
     return $this;
   }
 
   public function setLinesOfContext($lines_of_context) {
     $this->linesOfContext = $lines_of_context;
     return $this;
   }
 
   public function getLinesOfContext() {
     return $this->linesOfContext;
   }
 
 
   /**
    * Configure which Changeset comments added to the right side of the visible
    * diff will be attached to. The ID must be the ID of a real Differential
    * Changeset.
    *
    * The complexity here is that we may show an arbitrary side of an arbitrary
    * changeset as either the left or right part of a diff. This method allows
    * the left and right halves of the displayed diff to be correctly mapped to
    * storage changesets.
    *
    * @param id    The Differential Changeset ID that comments added to the right
    *              side of the visible diff should be attached to.
    * @param bool  If true, attach new comments to the right side of the storage
    *              changeset. Note that this may be false, if the left side of
    *              some storage changeset is being shown as the right side of
    *              a display diff.
    * @return this
    */
   public function setRightSideCommentMapping($id, $is_new) {
     $this->rightSideChangesetID = $id;
     $this->rightSideAttachesToNewFile = $is_new;
     return $this;
   }
 
   /**
    * See setRightSideCommentMapping(), but this sets information for the left
    * side of the display diff.
    */
   public function setLeftSideCommentMapping($id, $is_new) {
     $this->leftSideChangesetID = $id;
     $this->leftSideAttachesToNewFile = $is_new;
     return $this;
   }
 
   public function setOriginals(
     DifferentialChangeset $left,
     DifferentialChangeset $right) {
 
     $this->originalLeft = $left;
     $this->originalRight = $right;
     return $this;
   }
 
   public function diffOriginals() {
     $engine = new PhabricatorDifferenceEngine();
     $changeset = $engine->generateChangesetFromFileContent(
       implode('', mpull($this->originalLeft->getHunks(), 'getChanges')),
       implode('', mpull($this->originalRight->getHunks(), 'getChanges')));
 
     $parser = new DifferentialHunkParser();
 
     return $parser->parseHunksForHighlightMasks(
       $changeset->getHunks(),
       $this->originalLeft->getHunks(),
       $this->originalRight->getHunks());
   }
 
   /**
    * Set a key for identifying this changeset in the render cache. If set, the
    * parser will attempt to use the changeset render cache, which can improve
    * performance for frequently-viewed changesets.
    *
    * By default, there is no render cache key and parsers do not use the cache.
    * This is appropriate for rarely-viewed changesets.
    *
    * NOTE: Currently, this key must be a valid Differential Changeset ID.
    *
    * @param   string  Key for identifying this changeset in the render cache.
    * @return  this
    */
   public function setRenderCacheKey($key) {
     $this->renderCacheKey = $key;
     return $this;
   }
 
   private function getRenderCacheKey() {
     return $this->renderCacheKey;
   }
 
   public function setChangeset(DifferentialChangeset $changeset) {
     $this->changeset = $changeset;
 
     $this->setFilename($changeset->getFilename());
 
     return $this;
   }
 
   public function setRenderingReference($ref) {
     $this->renderingReference = $ref;
     return $this;
   }
 
   private function getRenderingReference() {
     return $this->renderingReference;
   }
 
   public function getChangeset() {
     return $this->changeset;
   }
 
   public function setFilename($filename) {
     $this->filename = $filename;
     return $this;
   }
 
   public function setHandles(array $handles) {
     assert_instances_of($handles, 'PhabricatorObjectHandle');
     $this->handles = $handles;
     return $this;
   }
 
   public function setMarkupEngine(PhabricatorMarkupEngine $engine) {
     $this->markupEngine = $engine;
     return $this;
   }
 
   public function setUser(PhabricatorUser $user) {
     $this->user = $user;
     return $this;
   }
 
   public function getUser() {
     return $this->user;
   }
 
   public function setCoverage($coverage) {
     $this->coverage = $coverage;
     return $this;
   }
   private function getCoverage() {
     return $this->coverage;
   }
 
   public function parseInlineComment(
     PhabricatorInlineCommentInterface $comment) {
 
     // Parse only comments which are actually visible.
     if ($this->isCommentVisibleOnRenderedDiff($comment)) {
       $this->comments[] = $comment;
     }
     return $this;
   }
 
   private function loadCache() {
     $render_cache_key = $this->getRenderCacheKey();
     if (!$render_cache_key) {
       return false;
     }
 
     $data = null;
 
     $changeset = new DifferentialChangeset();
     $conn_r = $changeset->establishConnection('r');
     $data = queryfx_one(
       $conn_r,
       'SELECT * FROM %T WHERE id = %d',
       $changeset->getTableName().'_parse_cache',
       $render_cache_key);
 
     if (!$data) {
       return false;
     }
 
     if ($data['cache'][0] == '{') {
       // This is likely an old-style JSON cache which we will not be able to
       // deserialize.
       return false;
     }
 
     $data = unserialize($data['cache']);
     if (!is_array($data) || !$data) {
       return false;
     }
 
     foreach (self::getCacheableProperties() as $cache_key) {
       if (!array_key_exists($cache_key, $data)) {
         // If we're missing a cache key, assume we're looking at an old cache
         // and ignore it.
         return false;
       }
     }
 
     if ($data['cacheVersion'] !== self::CACHE_VERSION) {
       return false;
     }
 
     // Someone displays contents of a partially cached shielded file.
     if (!isset($data['newRender']) && (!$this->isTopLevel || $this->comments)) {
       return false;
     }
 
     unset($data['cacheVersion'], $data['cacheHost']);
     $cache_prop = array_select_keys($data, self::getCacheableProperties());
     foreach ($cache_prop as $cache_key => $v) {
       $this->$cache_key = $v;
     }
 
     return true;
   }
 
   protected static function getCacheableProperties() {
     return array(
       'visible',
       'new',
       'old',
       'intra',
       'depthOnlyLines',
       'newRender',
       'oldRender',
       'specialAttributes',
       'hunkStartLines',
       'cacheVersion',
       'cacheHost',
       'highlightingDisabled',
     );
   }
 
   public function saveCache() {
     if (PhabricatorEnv::isReadOnly()) {
       return false;
     }
 
     if ($this->highlightErrors) {
       return false;
     }
 
     $render_cache_key = $this->getRenderCacheKey();
     if (!$render_cache_key) {
       return false;
     }
 
     $cache = array();
     foreach (self::getCacheableProperties() as $cache_key) {
       switch ($cache_key) {
         case 'cacheVersion':
           $cache[$cache_key] = self::CACHE_VERSION;
           break;
         case 'cacheHost':
           $cache[$cache_key] = php_uname('n');
           break;
         default:
           $cache[$cache_key] = $this->$cache_key;
           break;
       }
     }
     $cache = serialize($cache);
 
     // We don't want to waste too much space by a single changeset.
     if (strlen($cache) > self::CACHE_MAX_SIZE) {
       return;
     }
 
     $changeset = new DifferentialChangeset();
     $conn_w = $changeset->establishConnection('w');
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       try {
         queryfx(
           $conn_w,
           'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %B, %d)
             ON DUPLICATE KEY UPDATE cache = VALUES(cache)',
           DifferentialChangeset::TABLE_CACHE,
           $render_cache_key,
           $cache,
           time());
       } catch (AphrontQueryException $ex) {
         // Ignore these exceptions. A common cause is that the cache is
         // larger than 'max_allowed_packet', in which case we're better off
         // not writing it.
 
         // TODO: It would be nice to tailor this more narrowly.
       }
     unset($unguarded);
   }
 
   private function markGenerated($new_corpus_block = '') {
     $generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false);
 
     if (!$generated_guess) {
       $generated_path_regexps = PhabricatorEnv::getEnvConfig(
         'differential.generated-paths');
       foreach ($generated_path_regexps as $regexp) {
         if (preg_match($regexp, $this->changeset->getFilename())) {
           $generated_guess = true;
           break;
         }
       }
     }
 
     $event = new PhabricatorEvent(
       PhabricatorEventType::TYPE_DIFFERENTIAL_WILLMARKGENERATED,
       array(
         'corpus' => $new_corpus_block,
         'is_generated' => $generated_guess,
       )
     );
     PhutilEventEngine::dispatchEvent($event);
 
     $generated = $event->getValue('is_generated');
 
     $attribute = $this->changeset->isGeneratedChangeset();
     if ($attribute) {
       $generated = true;
     }
 
     $this->specialAttributes[self::ATTR_GENERATED] = $generated;
   }
 
   public function isGenerated() {
     return idx($this->specialAttributes, self::ATTR_GENERATED, false);
   }
 
   public function isDeleted() {
     return idx($this->specialAttributes, self::ATTR_DELETED, false);
   }
 
   public function isUnchanged() {
     return idx($this->specialAttributes, self::ATTR_UNCHANGED, false);
   }
 
   public function isMoveAway() {
     return idx($this->specialAttributes, self::ATTR_MOVEAWAY, false);
   }
 
   private function applyIntraline(&$render, $intra, $corpus) {
 
     foreach ($render as $key => $text) {
       $result = $text;
 
       if (isset($intra[$key])) {
         $result = ArcanistDiffUtils::applyIntralineDiff(
           $result,
           $intra[$key]);
       }
 
       $result = $this->adjustRenderedLineForDisplay($result);
 
       $render[$key] = $result;
     }
   }
 
   private function getHighlightFuture($corpus) {
     $language = $this->highlightAs;
 
     if (!$language) {
       $language = $this->highlightEngine->getLanguageFromFilename(
         $this->filename);
 
       if (($language != 'txt') &&
           (strlen($corpus) > self::HIGHLIGHT_BYTE_LIMIT)) {
         $this->highlightingDisabled = true;
         $language = 'txt';
       }
     }
 
     return $this->highlightEngine->getHighlightFuture(
       $language,
       $corpus);
   }
 
   protected function processHighlightedSource($data, $result) {
 
     $result_lines = phutil_split_lines($result);
     foreach ($data as $key => $info) {
       if (!$info) {
         unset($result_lines[$key]);
       }
     }
     return $result_lines;
   }
 
   private function tryCacheStuff() {
     $skip_cache = false;
 
     if ($this->disableCache) {
       $skip_cache = true;
     }
 
     if ($this->characterEncoding) {
       $skip_cache = true;
     }
 
     if ($this->highlightAs) {
       $skip_cache = true;
     }
 
     $changeset = $this->changeset;
 
     if ($changeset->getFileType() != DifferentialChangeType::FILE_TEXT &&
         $changeset->getFileType() != DifferentialChangeType::FILE_SYMLINK) {
 
       $this->markGenerated();
 
     } else {
       if ($skip_cache || !$this->loadCache()) {
         $this->process();
         if (!$skip_cache) {
           $this->saveCache();
         }
       }
     }
   }
 
   private function process() {
     $changeset = $this->changeset;
 
     $hunk_parser = new DifferentialHunkParser();
     $hunk_parser->parseHunksForLineData($changeset->getHunks());
 
     $this->realignDiff($changeset, $hunk_parser);
 
     $hunk_parser->reparseHunksForSpecialAttributes();
 
     $unchanged = false;
     if (!$hunk_parser->getHasAnyChanges()) {
       $filetype = $this->changeset->getFileType();
       if ($filetype == DifferentialChangeType::FILE_TEXT ||
           $filetype == DifferentialChangeType::FILE_SYMLINK) {
         $unchanged = true;
       }
     }
 
     $moveaway = false;
     $changetype = $this->changeset->getChangeType();
     if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) {
       $moveaway = true;
     }
 
     $this->setSpecialAttributes(array(
       self::ATTR_UNCHANGED  => $unchanged,
       self::ATTR_DELETED    => $hunk_parser->getIsDeleted(),
       self::ATTR_MOVEAWAY   => $moveaway,
     ));
 
     $lines_context = $this->getLinesOfContext();
 
     $hunk_parser->generateIntraLineDiffs();
     $hunk_parser->generateVisibileLinesMask($lines_context);
 
     $this->setOldLines($hunk_parser->getOldLines());
     $this->setNewLines($hunk_parser->getNewLines());
     $this->setIntraLineDiffs($hunk_parser->getIntraLineDiffs());
     $this->setDepthOnlyLines($hunk_parser->getDepthOnlyLines());
     $this->setVisibileLinesMask($hunk_parser->getVisibleLinesMask());
     $this->hunkStartLines = $hunk_parser->getHunkStartLines(
       $changeset->getHunks());
 
     $new_corpus = $hunk_parser->getNewCorpus();
     $new_corpus_block = implode('', $new_corpus);
     $this->markGenerated($new_corpus_block);
 
     if ($this->isTopLevel &&
         !$this->comments &&
           ($this->isGenerated() ||
            $this->isUnchanged() ||
            $this->isDeleted())) {
       return;
     }
 
     $old_corpus = $hunk_parser->getOldCorpus();
     $old_corpus_block = implode('', $old_corpus);
     $old_future = $this->getHighlightFuture($old_corpus_block);
     $new_future = $this->getHighlightFuture($new_corpus_block);
     $futures = array(
       'old' => $old_future,
       'new' => $new_future,
     );
     $corpus_blocks = array(
       'old' => $old_corpus_block,
       'new' => $new_corpus_block,
     );
 
     $this->highlightErrors = false;
     foreach (new FutureIterator($futures) as $key => $future) {
       try {
         try {
           $highlighted = $future->resolve();
         } catch (PhutilSyntaxHighlighterException $ex) {
           $this->highlightErrors = true;
           $highlighted = id(new PhutilDefaultSyntaxHighlighter())
             ->getHighlightFuture($corpus_blocks[$key])
             ->resolve();
         }
         switch ($key) {
         case 'old':
           $this->oldRender = $this->processHighlightedSource(
             $this->old,
             $highlighted);
           break;
         case 'new':
           $this->newRender = $this->processHighlightedSource(
             $this->new,
             $highlighted);
           break;
         }
       } catch (Exception $ex) {
         phlog($ex);
         throw $ex;
       }
     }
 
     $this->applyIntraline(
       $this->oldRender,
       ipull($this->intra, 0),
       $old_corpus);
     $this->applyIntraline(
       $this->newRender,
       ipull($this->intra, 1),
       $new_corpus);
   }
 
   private function shouldRenderPropertyChangeHeader($changeset) {
     if (!$this->isTopLevel) {
       // We render properties only at top level; otherwise we get multiple
       // copies of them when a user clicks "Show More".
       return false;
     }
 
     return true;
   }
 
   public function render(
     $range_start  = null,
     $range_len    = null,
     $mask_force   = array()) {
 
     // "Top level" renders are initial requests for the whole file, versus
     // requests for a specific range generated by clicking "show more". We
     // generate property changes and "shield" UI elements only for toplevel
     // requests.
     $this->isTopLevel = (($range_start === null) && ($range_len === null));
     $this->highlightEngine = PhabricatorSyntaxHighlighter::newEngine();
 
     $encoding = null;
     if ($this->characterEncoding) {
       // We are forcing this changeset to be interpreted with a specific
       // character encoding, so force all the hunks into that encoding and
       // propagate it to the renderer.
       $encoding = $this->characterEncoding;
       foreach ($this->changeset->getHunks() as $hunk) {
         $hunk->forceEncoding($this->characterEncoding);
       }
     } else {
       // We're just using the default, so tell the renderer what that is
       // (by reading the encoding from the first hunk).
       foreach ($this->changeset->getHunks() as $hunk) {
         $encoding = $hunk->getDataEncoding();
         break;
       }
     }
 
     $this->tryCacheStuff();
 
     // If we're rendering in an offset mode, treat the range numbers as line
     // numbers instead of rendering offsets.
     $offset_mode = $this->getOffsetMode();
     if ($offset_mode) {
       if ($offset_mode == 'new') {
         $offset_map = $this->new;
       } else {
         $offset_map = $this->old;
       }
 
       // NOTE: Inline comments use zero-based lengths. For example, a comment
       // that starts and ends on line 123 has length 0. Rendering considers
       // this range to have length 1. Probably both should agree, but that
       // ship likely sailed long ago. Tweak things here to get the two systems
       // to agree. See PHI985, where this affected mail rendering of inline
       // comments left on the final line of a file.
 
       $range_end = $this->getOffset($offset_map, $range_start + $range_len);
       $range_start = $this->getOffset($offset_map, $range_start);
       $range_len = ($range_end - $range_start) + 1;
     }
 
     $render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset);
 
     $rows = max(
       count($this->old),
       count($this->new));
 
     $renderer = $this->getRenderer()
       ->setUser($this->getUser())
       ->setChangeset($this->changeset)
       ->setRenderPropertyChangeHeader($render_pch)
       ->setIsTopLevel($this->isTopLevel)
       ->setOldRender($this->oldRender)
       ->setNewRender($this->newRender)
       ->setHunkStartLines($this->hunkStartLines)
       ->setOldChangesetID($this->leftSideChangesetID)
       ->setNewChangesetID($this->rightSideChangesetID)
       ->setOldAttachesToNewFile($this->leftSideAttachesToNewFile)
       ->setNewAttachesToNewFile($this->rightSideAttachesToNewFile)
       ->setCodeCoverage($this->getCoverage())
       ->setRenderingReference($this->getRenderingReference())
       ->setMarkupEngine($this->markupEngine)
       ->setHandles($this->handles)
       ->setOldLines($this->old)
       ->setNewLines($this->new)
       ->setOriginalCharacterEncoding($encoding)
       ->setShowEditAndReplyLinks($this->getShowEditAndReplyLinks())
       ->setCanMarkDone($this->getCanMarkDone())
       ->setObjectOwnerPHID($this->getObjectOwnerPHID())
       ->setHighlightingDisabled($this->highlightingDisabled)
       ->setDepthOnlyLines($this->getDepthOnlyLines());
 
     $shield = null;
     if ($this->isTopLevel && !$this->comments) {
       if ($this->isGenerated()) {
         $shield = $renderer->renderShield(
           pht(
             'This file contains generated code, which does not normally '.
             'need to be reviewed.'));
       } else if ($this->isMoveAway()) {
         // We put an empty shield on these files. Normally, they do not have
         // any diff content anyway. However, if they come through `arc`, they
         // may have content. We don't want to show it (it's not useful) and
         // we bailed out of fully processing it earlier anyway.
 
         // We could show a message like "this file was moved", but we show
         // that as a change header anyway, so it would be redundant. Instead,
         // just render an empty shield to skip rendering the diff body.
         $shield = '';
       } else if ($this->isUnchanged()) {
         $type = 'text';
         if (!$rows) {
           // NOTE: Normally, diffs which don't change files do not include
           // file content (for example, if you "chmod +x" a file and then
           // run "git show", the file content is not available). Similarly,
           // if you move a file from A to B without changing it, diffs normally
           // do not show the file content. In some cases `arc` is able to
           // synthetically generate content for these diffs, but for raw diffs
           // we'll never have it so we need to be prepared to not render a link.
           $type = 'none';
         }
 
         $type_add = DifferentialChangeType::TYPE_ADD;
         if ($this->changeset->getChangeType() == $type_add) {
           // Although the generic message is sort of accurate in a technical
           // sense, this more-tailored message is less confusing.
           $shield = $renderer->renderShield(
             pht('This is an empty file.'),
             $type);
         } else {
           $shield = $renderer->renderShield(
             pht('The contents of this file were not changed.'),
             $type);
         }
       } else if ($this->isDeleted()) {
         $shield = $renderer->renderShield(
           pht('This file was completely deleted.'));
       } else if ($this->changeset->getAffectedLineCount() > 2500) {
         $shield = $renderer->renderShield(
           pht(
             'This file has a very large number of changes (%s lines).',
             new PhutilNumber($this->changeset->getAffectedLineCount())));
       }
     }
 
     if ($shield !== null) {
       return $renderer->renderChangesetTable($shield);
     }
 
     // This request should render the "undershield" headers if it's a top-level
     // request which made it this far (indicating the changeset has no shield)
     // or it's a request with no mask information (indicating it's the request
     // that removes the rendering shield). Possibly, this second class of
     // request might need to be made more explicit.
     $is_undershield = (empty($mask_force) || $this->isTopLevel);
     $renderer->setIsUndershield($is_undershield);
 
     $old_comments = array();
     $new_comments = array();
     $old_mask = array();
     $new_mask = array();
     $feedback_mask = array();
     $lines_context = $this->getLinesOfContext();
 
     if ($this->comments) {
       // If there are any comments which appear in sections of the file which
       // we don't have, we're going to move them backwards to the closest
       // earlier line. Two cases where this may happen are:
       //
       //   - Porting ghost comments forward into a file which was mostly
       //     deleted.
       //   - Porting ghost comments forward from a full-context diff to a
       //     partial-context diff.
 
       list($old_backmap, $new_backmap) = $this->buildLineBackmaps();
 
       foreach ($this->comments as $comment) {
         $new_side = $this->isCommentOnRightSideWhenDisplayed($comment);
 
         $line = $comment->getLineNumber();
         if ($new_side) {
           $back_line = $new_backmap[$line];
         } else {
           $back_line = $old_backmap[$line];
         }
 
         if ($back_line != $line) {
           // TODO: This should probably be cleaner, but just be simple and
           // obvious for now.
           $ghost = $comment->getIsGhost();
           if ($ghost) {
             $moved = pht(
               'This comment originally appeared on line %s, but that line '.
               'does not exist in this version of the diff. It has been '.
               'moved backward to the nearest line.',
               new PhutilNumber($line));
             $ghost['reason'] = $ghost['reason']."\n\n".$moved;
             $comment->setIsGhost($ghost);
           }
 
           $comment->setLineNumber($back_line);
           $comment->setLineLength(0);
 
         }
 
         $start = max($comment->getLineNumber() - $lines_context, 0);
         $end = $comment->getLineNumber() +
           $comment->getLineLength() +
           $lines_context;
         for ($ii = $start; $ii <= $end; $ii++) {
           if ($new_side) {
             $new_mask[$ii] = true;
           } else {
             $old_mask[$ii] = true;
           }
         }
       }
 
       foreach ($this->old as $ii => $old) {
         if (isset($old['line']) && isset($old_mask[$old['line']])) {
           $feedback_mask[$ii] = true;
         }
       }
 
       foreach ($this->new as $ii => $new) {
         if (isset($new['line']) && isset($new_mask[$new['line']])) {
           $feedback_mask[$ii] = true;
         }
       }
 
       $this->comments = id(new PHUIDiffInlineThreader())
         ->reorderAndThreadCommments($this->comments);
 
       foreach ($this->comments as $comment) {
         $final = $comment->getLineNumber() +
           $comment->getLineLength();
         $final = max(1, $final);
         if ($this->isCommentOnRightSideWhenDisplayed($comment)) {
           $new_comments[$final][] = $comment;
         } else {
           $old_comments[$final][] = $comment;
         }
       }
     }
     $renderer
       ->setOldComments($old_comments)
       ->setNewComments($new_comments);
 
+    $engine_view = $this->newDocumentEngineChangesetView();
+    if ($engine_view !== null) {
+      return $engine_view;
+    }
+
     switch ($this->changeset->getFileType()) {
       case DifferentialChangeType::FILE_IMAGE:
         $old = null;
         $new = null;
         // TODO: Improve the architectural issue as discussed in D955
         // https://secure.phabricator.com/D955
         $reference = $this->getRenderingReference();
         $parts = explode('/', $reference);
         if (count($parts) == 2) {
           list($id, $vs) = $parts;
         } else {
           $id = $parts[0];
           $vs = 0;
         }
         $id = (int)$id;
         $vs = (int)$vs;
 
         if (!$vs) {
           $metadata = $this->changeset->getMetadata();
           $data = idx($metadata, 'attachment-data');
 
           $old_phid = idx($metadata, 'old:binary-phid');
           $new_phid = idx($metadata, 'new:binary-phid');
         } else {
           $vs_changeset = id(new DifferentialChangeset())->load($vs);
           $old_phid = null;
           $new_phid = null;
 
           // TODO: This is spooky, see D6851
           if ($vs_changeset) {
             $vs_metadata = $vs_changeset->getMetadata();
             $old_phid = idx($vs_metadata, 'new:binary-phid');
           }
 
           $changeset = id(new DifferentialChangeset())->load($id);
           if ($changeset) {
             $metadata = $changeset->getMetadata();
             $new_phid = idx($metadata, 'new:binary-phid');
           }
         }
 
         if ($old_phid || $new_phid) {
           // grab the files, (micro) optimization for 1 query not 2
           $file_phids = array();
           if ($old_phid) {
             $file_phids[] = $old_phid;
           }
           if ($new_phid) {
             $file_phids[] = $new_phid;
           }
 
           $files = id(new PhabricatorFileQuery())
             ->setViewer($this->getUser())
             ->withPHIDs($file_phids)
             ->execute();
           foreach ($files as $file) {
             if (empty($file)) {
               continue;
             }
             if ($file->getPHID() == $old_phid) {
               $old = $file;
             } else if ($file->getPHID() == $new_phid) {
               $new = $file;
             }
           }
         }
 
         $renderer->attachOldFile($old);
         $renderer->attachNewFile($new);
 
         return $renderer->renderFileChange($old, $new, $id, $vs);
       case DifferentialChangeType::FILE_DIRECTORY:
       case DifferentialChangeType::FILE_BINARY:
         $output = $renderer->renderChangesetTable(null);
         return $output;
     }
 
     if ($this->originalLeft && $this->originalRight) {
       list($highlight_old, $highlight_new) = $this->diffOriginals();
       $highlight_old = array_flip($highlight_old);
       $highlight_new = array_flip($highlight_new);
       $renderer
         ->setHighlightOld($highlight_old)
         ->setHighlightNew($highlight_new);
     }
     $renderer
       ->setOriginalOld($this->originalLeft)
       ->setOriginalNew($this->originalRight);
 
     if ($range_start === null) {
       $range_start = 0;
     }
     if ($range_len === null) {
       $range_len = $rows;
     }
     $range_len = min($range_len, $rows - $range_start);
 
     list($gaps, $mask) = $this->calculateGapsAndMask(
       $mask_force,
       $feedback_mask,
       $range_start,
       $range_len);
 
     $renderer
       ->setGaps($gaps)
       ->setMask($mask);
 
     $html = $renderer->renderTextChange(
       $range_start,
       $range_len,
       $rows);
 
     return $renderer->renderChangesetTable($html);
   }
 
   /**
    * This function calculates a lot of stuff we need to know to display
    * the diff:
    *
    * Gaps - compute gaps in the visible display diff, where we will render
    * "Show more context" spacers. If a gap is smaller than the context size,
    * we just display it. Otherwise, we record it into $gaps and will render a
    * "show more context" element instead of diff text below. A given $gap
    * is a tuple of $gap_line_number_start and $gap_length.
    *
    * Mask - compute the actual lines that need to be shown (because they
    * are near changes lines, near inline comments, or the request has
    * explicitly asked for them, i.e. resulting from the user clicking
    * "show more"). The $mask returned is a sparsely populated dictionary
    * of $visible_line_number => true.
    *
    * @return array($gaps, $mask)
    */
   private function calculateGapsAndMask(
     $mask_force,
     $feedback_mask,
     $range_start,
     $range_len) {
 
     $lines_context = $this->getLinesOfContext();
 
     $gaps = array();
     $gap_start = 0;
     $in_gap = false;
     $base_mask = $this->visible + $mask_force + $feedback_mask;
     $base_mask[$range_start + $range_len] = true;
     for ($ii = $range_start; $ii <= $range_start + $range_len; $ii++) {
       if (isset($base_mask[$ii])) {
         if ($in_gap) {
           $gap_length = $ii - $gap_start;
           if ($gap_length <= $lines_context) {
             for ($jj = $gap_start; $jj <= $gap_start + $gap_length; $jj++) {
               $base_mask[$jj] = true;
             }
           } else {
             $gaps[] = array($gap_start, $gap_length);
           }
           $in_gap = false;
         }
       } else {
         if (!$in_gap) {
           $gap_start = $ii;
           $in_gap = true;
         }
       }
     }
     $gaps = array_reverse($gaps);
     $mask = $base_mask;
 
     return array($gaps, $mask);
   }
 
   /**
    * Determine if an inline comment will appear on the rendered diff,
    * taking into consideration which halves of which changesets will actually
    * be shown.
    *
    * @param PhabricatorInlineCommentInterface Comment to test for visibility.
    * @return bool True if the comment is visible on the rendered diff.
    */
   private function isCommentVisibleOnRenderedDiff(
     PhabricatorInlineCommentInterface $comment) {
 
     $changeset_id = $comment->getChangesetID();
     $is_new = $comment->getIsNewFile();
 
     if ($changeset_id == $this->rightSideChangesetID &&
         $is_new == $this->rightSideAttachesToNewFile) {
         return true;
     }
 
     if ($changeset_id == $this->leftSideChangesetID &&
         $is_new == $this->leftSideAttachesToNewFile) {
         return true;
     }
 
     return false;
   }
 
 
   /**
    * Determine if a comment will appear on the right side of the display diff.
    * Note that the comment must appear somewhere on the rendered changeset, as
    * per isCommentVisibleOnRenderedDiff().
    *
    * @param PhabricatorInlineCommentInterface Comment to test for display
    *              location.
    * @return bool True for right, false for left.
    */
   private function isCommentOnRightSideWhenDisplayed(
     PhabricatorInlineCommentInterface $comment) {
 
     if (!$this->isCommentVisibleOnRenderedDiff($comment)) {
       throw new Exception(pht('Comment is not visible on changeset!'));
     }
 
     $changeset_id = $comment->getChangesetID();
     $is_new = $comment->getIsNewFile();
 
     if ($changeset_id == $this->rightSideChangesetID &&
         $is_new == $this->rightSideAttachesToNewFile) {
       return true;
     }
 
     return false;
   }
 
   /**
    * Parse the 'range' specification that this class and the client-side JS
    * emit to indicate that a user clicked "Show more..." on a diff. Generally,
    * use is something like this:
    *
    *   $spec = $request->getStr('range');
    *   $parsed = DifferentialChangesetParser::parseRangeSpecification($spec);
    *   list($start, $end, $mask) = $parsed;
    *   $parser->render($start, $end, $mask);
    *
    * @param string Range specification, indicating the range of the diff that
    *               should be rendered.
    * @return tuple List of <start, end, mask> suitable for passing to
    *               @{method:render}.
    */
   public static function parseRangeSpecification($spec) {
     $range_s = null;
     $range_e = null;
     $mask = array();
 
     if ($spec) {
       $match = null;
       if (preg_match('@^(\d+)-(\d+)(?:/(\d+)-(\d+))?$@', $spec, $match)) {
         $range_s = (int)$match[1];
         $range_e = (int)$match[2];
         if (count($match) > 3) {
           $start = (int)$match[3];
           $len = (int)$match[4];
           for ($ii = $start; $ii < $start + $len; $ii++) {
             $mask[$ii] = true;
           }
         }
       }
     }
 
     return array($range_s, $range_e, $mask);
   }
 
   /**
    * Render "modified coverage" information; test coverage on modified lines.
    * This synthesizes diff information with unit test information into a useful
    * indicator of how well tested a change is.
    */
   public function renderModifiedCoverage() {
     $na = phutil_tag('em', array(), '-');
 
     $coverage = $this->getCoverage();
     if (!$coverage) {
       return $na;
     }
 
     $covered = 0;
     $not_covered = 0;
 
     foreach ($this->new as $k => $new) {
       if (!$new['line']) {
         continue;
       }
 
       if (!$new['type']) {
         continue;
       }
 
       if (empty($coverage[$new['line'] - 1])) {
         continue;
       }
 
       switch ($coverage[$new['line'] - 1]) {
         case 'C':
           $covered++;
           break;
         case 'U':
           $not_covered++;
           break;
       }
     }
 
     if (!$covered && !$not_covered) {
       return $na;
     }
 
     return sprintf('%d%%', 100 * ($covered / ($covered + $not_covered)));
   }
 
   /**
    * Build maps from lines comments appear on to actual lines.
    */
   private function buildLineBackmaps() {
     $old_back = array();
     $new_back = array();
     foreach ($this->old as $ii => $old) {
       $old_back[$old['line']] = $old['line'];
     }
     foreach ($this->new as $ii => $new) {
       $new_back[$new['line']] = $new['line'];
     }
 
     $max_old_line = 0;
     $max_new_line = 0;
     foreach ($this->comments as $comment) {
       if ($this->isCommentOnRightSideWhenDisplayed($comment)) {
         $max_new_line = max($max_new_line, $comment->getLineNumber());
       } else {
         $max_old_line = max($max_old_line, $comment->getLineNumber());
       }
     }
 
     $cursor = 1;
     for ($ii = 1; $ii <= $max_old_line; $ii++) {
       if (empty($old_back[$ii])) {
         $old_back[$ii] = $cursor;
       } else {
         $cursor = $old_back[$ii];
       }
     }
 
     $cursor = 1;
     for ($ii = 1; $ii <= $max_new_line; $ii++) {
       if (empty($new_back[$ii])) {
         $new_back[$ii] = $cursor;
       } else {
         $cursor = $new_back[$ii];
       }
     }
 
     return array($old_back, $new_back);
   }
 
   private function getOffset(array $map, $line) {
     if (!$map) {
       return null;
     }
 
     $line = (int)$line;
     foreach ($map as $key => $spec) {
       if ($spec && isset($spec['line'])) {
         if ((int)$spec['line'] >= $line) {
           return $key;
         }
       }
     }
 
     return $key;
   }
 
   private function realignDiff(
     DifferentialChangeset $changeset,
     DifferentialHunkParser $hunk_parser) {
     // Normalizing and realigning the diff depends on rediffing the files, and
     // we currently need complete representations of both files to do anything
     // reasonable. If we only have parts of the files, skip realignment.
 
     // We have more than one hunk, so we're definitely missing part of the file.
     $hunks = $changeset->getHunks();
     if (count($hunks) !== 1) {
       return null;
     }
 
     // The first hunk doesn't start at the beginning of the file, so we're
     // missing some context.
     $first_hunk = head($hunks);
     if ($first_hunk->getOldOffset() != 1 || $first_hunk->getNewOffset() != 1) {
       return null;
     }
 
     $old_file = $changeset->makeOldFile();
     $new_file = $changeset->makeNewFile();
     if ($old_file === $new_file) {
       // If the old and new files are exactly identical, the synthetic
       // diff below will give us nonsense and whitespace modes are
       // irrelevant anyway. This occurs when you, e.g., copy a file onto
       // itself in Subversion (see T271).
       return null;
     }
 
 
     $engine = id(new PhabricatorDifferenceEngine())
       ->setNormalize(true);
 
     $normalized_changeset = $engine->generateChangesetFromFileContent(
       $old_file,
       $new_file);
 
     $type_parser = new DifferentialHunkParser();
     $type_parser->parseHunksForLineData($normalized_changeset->getHunks());
 
     $hunk_parser->setNormalized(true);
     $hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap());
     $hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap());
   }
 
   private function adjustRenderedLineForDisplay($line) {
     // IMPORTANT: We're using "str_replace()" against raw HTML here, which can
     // easily become unsafe. The input HTML has already had syntax highlighting
     // and intraline diff highlighting applied, so it's full of "<span />" tags.
 
     static $search;
     static $replace;
     if ($search === null) {
       $rules = $this->newSuspiciousCharacterRules();
 
       $map = array();
       foreach ($rules as $key => $spec) {
         $tag = phutil_tag(
           'span',
           array(
             'data-copy-text' => $key,
             'class' => $spec['class'],
             'title' => $spec['title'],
           ),
           $spec['replacement']);
         $map[$key] = phutil_string_cast($tag);
       }
 
       $search = array_keys($map);
       $replace = array_values($map);
     }
 
     $is_html = false;
     if ($line instanceof PhutilSafeHTML) {
       $is_html = true;
       $line = hsprintf('%s', $line);
     }
 
     $line = phutil_string_cast($line);
 
     // TODO: This should be flexible, eventually.
     $tab_width = 2;
 
     $line = self::replaceTabsWithSpaces($line, $tab_width);
     $line = str_replace($search, $replace, $line);
 
     if ($is_html) {
       $line = phutil_safe_html($line);
     }
 
     return $line;
   }
 
   private function newSuspiciousCharacterRules() {
     // The "title" attributes are cached in the database, so they're
     // intentionally not wrapped in "pht(...)".
 
     $rules = array(
       "\xE2\x80\x8B" => array(
         'title' => 'ZWS',
         'class' => 'suspicious-character',
         'replacement' => '!',
       ),
       "\xC2\xA0" => array(
         'title' => 'NBSP',
         'class' => 'suspicious-character',
         'replacement' => '!',
       ),
       "\x7F" => array(
         'title' => 'DEL (0x7F)',
         'class' => 'suspicious-character',
         'replacement' => "\xE2\x90\xA1",
       ),
     );
 
     // Unicode defines special pictures for the control characters in the
     // range between "0x00" and "0x1F".
 
     $control = array(
       'NULL',
       'SOH',
       'STX',
       'ETX',
       'EOT',
       'ENQ',
       'ACK',
       'BEL',
       'BS',
       null, // "\t" Tab
       null, // "\n" New Line
       'VT',
       'FF',
       null, // "\r" Carriage Return,
       'SO',
       'SI',
       'DLE',
       'DC1',
       'DC2',
       'DC3',
       'DC4',
       'NAK',
       'SYN',
       'ETB',
       'CAN',
       'EM',
       'SUB',
       'ESC',
       'FS',
       'GS',
       'RS',
       'US',
     );
 
     foreach ($control as $idx => $label) {
       if ($label === null) {
         continue;
       }
 
       $rules[chr($idx)] = array(
         'title' => sprintf('%s (0x%02X)', $label, $idx),
         'class' => 'suspicious-character',
         'replacement' => "\xE2\x90".chr(0x80 + $idx),
       );
     }
 
     return $rules;
   }
 
   public static function replaceTabsWithSpaces($line, $tab_width) {
     static $tags = array();
     if (empty($tags[$tab_width])) {
       for ($ii = 1; $ii <= $tab_width; $ii++) {
         $tag = phutil_tag(
           'span',
           array(
             'data-copy-text' => "\t",
           ),
           str_repeat(' ', $ii));
         $tag = phutil_string_cast($tag);
         $tags[$ii] = $tag;
       }
     }
 
     // Expand all prefix tabs until we encounter any non-tab character. This
     // is cheap and often immediately produces the correct result with no
     // further work (and, particularly, no need to handle any unicode cases).
 
     $len = strlen($line);
 
     $head = 0;
     for ($head = 0; $head < $len; $head++) {
       $char = $line[$head];
       if ($char !== "\t") {
         break;
       }
     }
 
     if ($head) {
       if (empty($tags[$tab_width * $head])) {
         $tags[$tab_width * $head] = str_repeat($tags[$tab_width], $head);
       }
       $prefix = $tags[$tab_width * $head];
       $line = substr($line, $head);
     } else {
       $prefix = '';
     }
 
     // If we have no remaining tabs elsewhere in the string after taking care
     // of all the prefix tabs, we're done.
     if (strpos($line, "\t") === false) {
       return $prefix.$line;
     }
 
     $len = strlen($line);
 
     // If the line is particularly long, don't try to do anything special with
     // it. Use a faster approximation of the correct tabstop expansion instead.
     // This usually still arrives at the right result.
     if ($len > 256) {
       return $prefix.str_replace("\t", $tags[$tab_width], $line);
     }
 
     $in_tag = false;
     $pos = 0;
 
     // See PHI1210. If the line only has single-byte characters, we don't need
     // to vectorize it and can avoid an expensive UTF8 call.
 
     $fast_path = preg_match('/^[\x01-\x7F]*\z/', $line);
     if ($fast_path) {
       $replace = array();
       for ($ii = 0; $ii < $len; $ii++) {
         $char = $line[$ii];
         if ($char === '>') {
           $in_tag = false;
           continue;
         }
 
         if ($in_tag) {
           continue;
         }
 
         if ($char === '<') {
           $in_tag = true;
           continue;
         }
 
         if ($char === "\t") {
           $count = $tab_width - ($pos % $tab_width);
           $pos += $count;
           $replace[$ii] = $tags[$count];
           continue;
         }
 
         $pos++;
       }
 
       if ($replace) {
         // Apply replacements starting at the end of the string so they
         // don't mess up the offsets for following replacements.
         $replace = array_reverse($replace, true);
 
         foreach ($replace as $replace_pos => $replacement) {
           $line = substr_replace($line, $replacement, $replace_pos, 1);
         }
       }
     } else {
       $line = phutil_utf8v_combined($line);
       foreach ($line as $key => $char) {
         if ($char === '>') {
           $in_tag = false;
           continue;
         }
 
         if ($in_tag) {
           continue;
         }
 
         if ($char === '<') {
           $in_tag = true;
           continue;
         }
 
         if ($char === "\t") {
           $count = $tab_width - ($pos % $tab_width);
           $pos += $count;
           $line[$key] = $tags[$count];
           continue;
         }
 
         $pos++;
       }
 
       $line = implode('', $line);
     }
 
     return $prefix.$line;
   }
 
+  private function newDocumentEngineChangesetView() {
+    $changeset = $this->changeset;
+    $viewer = $this->getViewer();
+
+    // TODO: This should probably be made non-optional in the future.
+    if (!$viewer) {
+      return null;
+    }
+
+    $old_data = $this->old;
+    $old_data = ipull($old_data, 'text');
+    $old_data = implode('', $old_data);
+
+    $new_data = $this->new;
+    $new_data = ipull($new_data, 'text');
+    $new_data = implode('', $new_data);
+
+    $old_ref = id(new PhabricatorDocumentRef())
+      ->setName($changeset->getOldFile())
+      ->setData($old_data);
+
+    $new_ref = id(new PhabricatorDocumentRef())
+      ->setName($changeset->getFilename())
+      ->setData($new_data);
+
+    $old_engines = PhabricatorDocumentEngine::getEnginesForRef(
+      $viewer,
+      $old_ref);
+
+    $new_engines = PhabricatorDocumentEngine::getEnginesForRef(
+      $viewer,
+      $new_ref);
+
+    $shared_engines = array_intersect_key($old_engines, $new_engines);
+
+    $document_engine = null;
+    foreach ($shared_engines as $shared_engine) {
+      if ($shared_engine->canDiffDocuments($old_ref, $new_ref)) {
+        $document_engine = $shared_engine;
+        break;
+      }
+    }
+
+
+    if ($document_engine) {
+      return $document_engine->newDiffView(
+        $old_ref,
+        $new_ref);
+    }
+
+    return null;
+  }
+
 }
diff --git a/src/applications/files/document/PhabricatorDocumentEngine.php b/src/applications/files/document/PhabricatorDocumentEngine.php
index c869f5f0d7..7e2a0d3b34 100644
--- a/src/applications/files/document/PhabricatorDocumentEngine.php
+++ b/src/applications/files/document/PhabricatorDocumentEngine.php
@@ -1,248 +1,260 @@
 <?php
 
 abstract class PhabricatorDocumentEngine
   extends Phobject {
 
   private $viewer;
   private $highlightedLines = array();
   private $encodingConfiguration;
   private $highlightingConfiguration;
   private $blameConfiguration = true;
 
   final public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   final public function getViewer() {
     return $this->viewer;
   }
 
   final public function setHighlightedLines(array $highlighted_lines) {
     $this->highlightedLines = $highlighted_lines;
     return $this;
   }
 
   final public function getHighlightedLines() {
     return $this->highlightedLines;
   }
 
   final public function canRenderDocument(PhabricatorDocumentRef $ref) {
     return $this->canRenderDocumentType($ref);
   }
 
+  public function canDiffDocuments(
+    PhabricatorDocumentRef $uref,
+    PhabricatorDocumentRef $vref) {
+    return false;
+  }
+
+  public function newDiffView(
+    PhabricatorDocumentRef $uref,
+    PhabricatorDocumentRef $vref) {
+    throw new PhutilMethodNotImplementedException();
+  }
+
   public function canConfigureEncoding(PhabricatorDocumentRef $ref) {
     return false;
   }
 
   public function canConfigureHighlighting(PhabricatorDocumentRef $ref) {
     return false;
   }
 
   public function canBlame(PhabricatorDocumentRef $ref) {
     return false;
   }
 
   final public function setEncodingConfiguration($config) {
     $this->encodingConfiguration = $config;
     return $this;
   }
 
   final public function getEncodingConfiguration() {
     return $this->encodingConfiguration;
   }
 
   final public function setHighlightingConfiguration($config) {
     $this->highlightingConfiguration = $config;
     return $this;
   }
 
   final public function getHighlightingConfiguration() {
     return $this->highlightingConfiguration;
   }
 
   final public function setBlameConfiguration($blame_configuration) {
     $this->blameConfiguration = $blame_configuration;
     return $this;
   }
 
   final public function getBlameConfiguration() {
     return $this->blameConfiguration;
   }
 
   final protected function getBlameEnabled() {
     return $this->blameConfiguration;
   }
 
   public function shouldRenderAsync(PhabricatorDocumentRef $ref) {
     return false;
   }
 
   abstract protected function canRenderDocumentType(
     PhabricatorDocumentRef $ref);
 
   final public function newDocument(PhabricatorDocumentRef $ref) {
     $can_complete = $this->canRenderCompleteDocument($ref);
     $can_partial = $this->canRenderPartialDocument($ref);
 
     if (!$can_complete && !$can_partial) {
       return $this->newMessage(
         pht(
           'This document is too large to be rendered inline. (The document '.
           'is %s bytes, the limit for this engine is %s bytes.)',
           new PhutilNumber($ref->getByteLength()),
           new PhutilNumber($this->getByteLengthLimit())));
     }
 
     return $this->newDocumentContent($ref);
   }
 
   final public function newDocumentIcon(PhabricatorDocumentRef $ref) {
     return id(new PHUIIconView())
       ->setIcon($this->getDocumentIconIcon($ref));
   }
 
   abstract protected function newDocumentContent(
     PhabricatorDocumentRef $ref);
 
   protected function getDocumentIconIcon(PhabricatorDocumentRef $ref) {
     return 'fa-file-o';
   }
 
   protected function getDocumentRenderingText(PhabricatorDocumentRef $ref) {
     return pht('Loading...');
   }
 
   final public function getDocumentEngineKey() {
     return $this->getPhobjectClassConstant('ENGINEKEY');
   }
 
   final public static function getAllEngines() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getDocumentEngineKey')
       ->execute();
   }
 
   final public function newSortVector(PhabricatorDocumentRef $ref) {
     $content_score = $this->getContentScore($ref);
 
     // Prefer engines which can render the entire file over engines which
     // can only render a header, and engines which can render a header over
     // engines which can't render anything.
     if ($this->canRenderCompleteDocument($ref)) {
       $limit_score = 0;
     } else if ($this->canRenderPartialDocument($ref)) {
       $limit_score = 1;
     } else {
       $limit_score = 2;
     }
 
     return id(new PhutilSortVector())
       ->addInt($limit_score)
       ->addInt(-$content_score);
   }
 
   protected function getContentScore(PhabricatorDocumentRef $ref) {
     return 2000;
   }
 
   abstract public function getViewAsLabel(PhabricatorDocumentRef $ref);
 
   public function getViewAsIconIcon(PhabricatorDocumentRef $ref) {
     $can_complete = $this->canRenderCompleteDocument($ref);
     $can_partial = $this->canRenderPartialDocument($ref);
 
     if (!$can_complete && !$can_partial) {
       return 'fa-times';
     }
 
     return $this->getDocumentIconIcon($ref);
   }
 
   public function getViewAsIconColor(PhabricatorDocumentRef $ref) {
     $can_complete = $this->canRenderCompleteDocument($ref);
 
     if (!$can_complete) {
       return 'grey';
     }
 
     return null;
   }
 
   final public static function getEnginesForRef(
     PhabricatorUser $viewer,
     PhabricatorDocumentRef $ref) {
     $engines = self::getAllEngines();
 
     foreach ($engines as $key => $engine) {
       $engine = id(clone $engine)
         ->setViewer($viewer);
 
       if (!$engine->canRenderDocument($ref)) {
         unset($engines[$key]);
         continue;
       }
 
       $engines[$key] = $engine;
     }
 
     if (!$engines) {
       throw new Exception(pht('No content engine can render this document.'));
     }
 
     $vectors = array();
     foreach ($engines as $key => $usable_engine) {
       $vectors[$key] = $usable_engine->newSortVector($ref);
     }
     $vectors = msortv($vectors, 'getSelf');
 
     return array_select_keys($engines, array_keys($vectors));
   }
 
   protected function getByteLengthLimit() {
     return (1024 * 1024 * 8);
   }
 
   protected function canRenderCompleteDocument(PhabricatorDocumentRef $ref) {
     $limit = $this->getByteLengthLimit();
     if ($limit) {
       $length = $ref->getByteLength();
       if ($length > $limit) {
         return false;
       }
     }
 
     return true;
   }
 
   protected function canRenderPartialDocument(PhabricatorDocumentRef $ref) {
     return false;
   }
 
   protected function newMessage($message) {
     return phutil_tag(
       'div',
       array(
         'class' => 'document-engine-error',
       ),
       $message);
   }
 
   final public function newLoadingContent(PhabricatorDocumentRef $ref) {
     $spinner = id(new PHUIIconView())
       ->setIcon('fa-gear')
       ->addClass('ph-spin');
 
     return phutil_tag(
       'div',
       array(
         'class' => 'document-engine-loading',
       ),
       array(
         $spinner,
         $this->getDocumentRenderingText($ref),
       ));
   }
 
 }
diff --git a/src/applications/files/document/PhabricatorDocumentRef.php b/src/applications/files/document/PhabricatorDocumentRef.php
index 036656c00e..12d9f4f2fd 100644
--- a/src/applications/files/document/PhabricatorDocumentRef.php
+++ b/src/applications/files/document/PhabricatorDocumentRef.php
@@ -1,178 +1,202 @@
 <?php
 
 final class PhabricatorDocumentRef
   extends Phobject {
 
   private $name;
   private $mimeType;
   private $file;
   private $byteLength;
   private $snippet;
   private $symbolMetadata = array();
   private $blameURI;
   private $coverage = array();
+  private $data;
 
   public function setFile(PhabricatorFile $file) {
     $this->file = $file;
     return $this;
   }
 
   public function getFile() {
     return $this->file;
   }
 
   public function setMimeType($mime_type) {
     $this->mimeType = $mime_type;
     return $this;
   }
 
   public function getMimeType() {
     if ($this->mimeType !== null) {
       return $this->mimeType;
     }
 
     if ($this->file) {
       return $this->file->getMimeType();
     }
 
     return null;
   }
 
   public function setName($name) {
     $this->name = $name;
     return $this;
   }
 
   public function getName() {
     if ($this->name !== null) {
       return $this->name;
     }
 
     if ($this->file) {
       return $this->file->getName();
     }
 
     return null;
   }
 
   public function setByteLength($length) {
     $this->byteLength = $length;
     return $this;
   }
 
   public function getByteLength() {
     if ($this->byteLength !== null) {
       return $this->byteLength;
     }
 
+    if ($this->data !== null) {
+      return strlen($this->data);
+    }
+
     if ($this->file) {
       return (int)$this->file->getByteSize();
     }
 
     return null;
   }
 
+  public function setData($data) {
+    $this->data = $data;
+    return $this;
+  }
+
   public function loadData($begin = null, $end = null) {
+    if ($this->data !== null) {
+      $data = $this->data;
+
+      if ($begin !== null && $end !== null) {
+        $data = substr($data, $begin, $end - $begin);
+      } else if ($begin !== null) {
+        $data = substr($data, $begin);
+      } else if ($end !== null) {
+        $data = substr($data, 0, $end);
+      }
+
+      return $data;
+    }
+
     if ($this->file) {
       $iterator = $this->file->getFileDataIterator($begin, $end);
 
       $result = '';
       foreach ($iterator as $chunk) {
         $result .= $chunk;
       }
       return $result;
     }
 
     throw new PhutilMethodNotImplementedException();
   }
 
   public function hasAnyMimeType(array $candidate_types) {
     $mime_full = $this->getMimeType();
     $mime_parts = explode(';', $mime_full);
 
     $mime_type = head($mime_parts);
     $mime_type = $this->normalizeMimeType($mime_type);
 
     foreach ($candidate_types as $candidate_type) {
       if ($this->normalizeMimeType($candidate_type) === $mime_type) {
         return true;
       }
     }
 
     return false;
   }
 
   private function normalizeMimeType($mime_type) {
     $mime_type = trim($mime_type);
     $mime_type = phutil_utf8_strtolower($mime_type);
     return $mime_type;
   }
 
   public function isProbablyText() {
     $snippet = $this->getSnippet();
     return (strpos($snippet, "\0") === false);
   }
 
   public function isProbablyJSON() {
     if (!$this->isProbablyText()) {
       return false;
     }
 
     $snippet = $this->getSnippet();
 
     // If the file is longer than the snippet, we don't detect the content
     // as JSON. We could use some kind of heuristic here if we wanted, but
     // see PHI749 for a false positive.
     if (strlen($snippet) < $this->getByteLength()) {
       return false;
     }
 
     // If the snippet is the whole file, just check if the snippet is valid
     // JSON. Note that `phutil_json_decode()` only accepts arrays and objects
     // as JSON, so this won't misfire on files with content like "3".
     try {
       phutil_json_decode($snippet);
       return true;
     } catch (Exception $ex) {
       return false;
     }
   }
 
   public function getSnippet() {
     if ($this->snippet === null) {
       $this->snippet = $this->loadData(null, (1024 * 1024 * 1));
     }
 
     return $this->snippet;
   }
 
   public function setSymbolMetadata(array $metadata) {
     $this->symbolMetadata = $metadata;
     return $this;
   }
 
   public function getSymbolMetadata() {
     return $this->symbolMetadata;
   }
 
   public function setBlameURI($blame_uri) {
     $this->blameURI = $blame_uri;
     return $this;
   }
 
   public function getBlameURI() {
     return $this->blameURI;
   }
 
   public function addCoverage($coverage) {
     $this->coverage[] = array(
       'data' => $coverage,
     );
     return $this;
   }
 
   public function getCoverage() {
     return $this->coverage;
   }
 
 }