diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php index c8b9ccfe6..8111be2fa 100644 --- a/src/applications/differential/parser/DifferentialChangesetParser.php +++ b/src/applications/differential/parser/DifferentialChangesetParser.php @@ -1,1193 +1,1200 @@ 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 setVisibileLinesMask(array $mask) { $this->visible = $mask; return $this; } /** * 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; } 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 setWhitespaceMode($whitespace_mode) { $this->whitespaceMode = $whitespace_mode; 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 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; } $data = json_decode($data['cache'], true); 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', 'newRender', 'oldRender', 'specialAttributes', 'hunkStartLines', 'cacheVersion', 'cacheHost', ); } public function saveCache() { 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 = json_encode($cache); // We don't want to waste too much space by a single changeset. if (strlen($cache) > self::CACHE_MAX_SIZE) { return; } try { $changeset = new DifferentialChangeset(); $conn_w = $changeset->establishConnection('w'); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); queryfx( $conn_w, 'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %s, %d) ON DUPLICATE KEY UPDATE cache = VALUES(cache)', DifferentialChangeset::TABLE_CACHE, $render_cache_key, $cache, time()); } catch (AphrontQueryException $ex) { // TODO: uhoh } } private function markGenerated($new_corpus_block = '') { $generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false); if (!$generated_guess) { $config_key = 'differential.generated-paths'; $generated_path_regexps = PhabricatorEnv::getEnvConfig($config_key); 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'); $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 isWhitespaceOnly() { return idx($this->specialAttributes, self::ATTR_WHITELINES, false); } private function applyIntraline(&$render, $intra, $corpus) { foreach ($render as $key => $text) { if (isset($intra[$key])) { $render[$key] = ArcanistDiffUtils::applyIntralineDiff( $text, $intra[$key]); } } } private function getHighlightFuture($corpus) { if (preg_match('/\r(?!\n)/', $corpus)) { // TODO: Pygments converts "\r" newlines into "\n" newlines, so we can't // use it on files with "\r" newlines. If we have "\r" not followed by // "\n" in the file, skip highlighting. $result = phutil_escape_html($corpus); return new ImmediateFuture($result); } return $this->highlightEngine->getHighlightFuture( $this->highlightEngine->getLanguageFromFilename($this->filename), $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() { $whitespace_mode = $this->whitespaceMode; switch ($whitespace_mode) { case self::WHITESPACE_SHOW_ALL: case self::WHITESPACE_IGNORE_TRAILING: case self::WHITESPACE_IGNORE_FORCE: break; default: $whitespace_mode = self::WHITESPACE_IGNORE_ALL; break; } $skip_cache = ($whitespace_mode != self::WHITESPACE_IGNORE_ALL); $this->whitespaceMode = $whitespace_mode; $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() { $whitespace_mode = $this->whitespaceMode; $changeset = $this->changeset; $ignore_all = (($whitespace_mode == self::WHITESPACE_IGNORE_ALL) || ($whitespace_mode == self::WHITESPACE_IGNORE_FORCE)); $force_ignore = ($whitespace_mode == self::WHITESPACE_IGNORE_FORCE); if (!$force_ignore) { if ($ignore_all && $changeset->getWhitespaceMatters()) { $ignore_all = false; } } // The "ignore all whitespace" algorithm 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, // don't use the "ignore all" algorithm. if ($ignore_all) { $hunks = $changeset->getHunks(); if (count($hunks) !== 1) { $ignore_all = false; } else { $first_hunk = reset($hunks); if ($first_hunk->getOldOffset() != 1 || $first_hunk->getNewOffset() != 1) { $ignore_all = false; } } } if ($ignore_all) { $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). $ignore_all = false; } } $hunk_parser = new DifferentialHunkParser(); $hunk_parser->setWhitespaceMode($whitespace_mode); $hunk_parser->parseHunksForLineData($changeset->getHunks()); // Depending on the whitespace mode, we may need to compute a different // set of changes than the set of changes in the hunk data (specificaly, // we might want to consider changed lines which have only whitespace // changes as unchanged). if ($ignore_all) { $engine = new PhabricatorDifferenceEngine(); $engine->setIgnoreWhitespace(true); $no_whitespace_changeset = $engine->generateChangesetFromFileContent( $old_file, $new_file); $type_parser = new DifferentialHunkParser(); $type_parser->parseHunksForLineData($no_whitespace_changeset->getHunks()); $hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap()); $hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap()); } $hunk_parser->reparseHunksForSpecialAttributes(); $unchanged = false; if (!$hunk_parser->getHasAnyChanges()) { $filetype = $this->changeset->getFileType(); if ($filetype == DifferentialChangeType::FILE_TEXT || $filetype == DifferentialChangeType::FILE_SYMLINK) { $unchanged = true; } } $changetype = $this->changeset->getChangeType(); if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) { // sometimes we show moved files as unchanged, sometimes deleted, // and sometimes inconsistent with what actually happened at the // destination of the move. Rather than make a false claim, // omit the 'not changed' notice if this is the source of a move $unchanged = false; } $this->setSpecialAttributes(array( self::ATTR_UNCHANGED => $unchanged, self::ATTR_DELETED => $hunk_parser->getIsDeleted(), self::ATTR_WHITELINES => !$hunk_parser->getHasTextChanges(), )); $hunk_parser->generateIntraLineDiffs(); $hunk_parser->generateVisibileLinesMask(); $this->setOldLines($hunk_parser->getOldLines()); $this->setNewLines($hunk_parser->getNewLines()); $this->setIntraLineDiffs($hunk_parser->getIntraLineDiffs()); $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 (Futures($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; } $old = $changeset->getOldProperties(); $new = $changeset->getNewProperties(); if ($old === $new) { return false; } if ($changeset->getChangeType() == DifferentialChangeType::TYPE_ADD && $new == array('unix:filemode' => '100644')) { return false; } if ($changeset->getChangeType() == DifferentialChangeType::TYPE_DELETE && $old == array('unix:filemode' => '100644')) { 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(); $this->tryCacheStuff(); $render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset); $rows = max( count($this->old), count($this->new)); $renderer = id(new DifferentialChangesetTwoUpRenderer()) ->setChangeset($this->changeset) ->setRenderPropertyChangeHeader($render_pch) ->setLineCount($rows) ->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); if ($this->user) { $renderer->setUser($this->user); } $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.'), - true); + 'need to be reviewed.')); } else if ($this->isUnchanged()) { - $shield = $renderer->renderShield( - pht("The contents of this file were not changed."), - false); + $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'; + } + $shield = $renderer->renderShield( + pht('The contents of this file were not changed.'), + $type); } else if ($this->isWhitespaceOnly()) { $shield = $renderer->renderShield( - pht( - 'This file was changed only by adding or removing whitespace.'), - false); + pht('This file was changed only by adding or removing whitespace.'), + 'whitespace'); } else if ($this->isDeleted()) { $shield = $renderer->renderShield( - pht("This file was completely deleted."), - true); + pht('This file was completely deleted.')); } else if ($this->changeset->getAffectedLineCount() > 2500) { $lines = number_format($this->changeset->getAffectedLineCount()); $shield = $renderer->renderShield( pht( 'This file has a very large number of changes ({%s} lines).', - $lines), - true); + $lines)); } } if ($shield) { return $renderer->renderChangesetTable($shield); } $old_comments = array(); $new_comments = array(); $old_mask = array(); $new_mask = array(); $feedback_mask = array(); if ($this->comments) { foreach ($this->comments as $comment) { $start = max($comment->getLineNumber() - self::LINES_CONTEXT, 0); $end = $comment->getLineNumber() + $comment->getLineLength() + self::LINES_CONTEXT; $new_side = $this->isCommentOnRightSideWhenDisplayed($comment); 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 = msort($this->comments, 'getID'); 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); 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); $vs_metadata = $vs_changeset->getMetadata(); $old_phid = idx($vs_metadata, 'new:binary-phid'); $changeset = id(new DifferentialChangeset())->load($id); $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 PhabricatorFile())->loadAllWhere( 'phid IN (%Ls)', $file_phids); foreach ($files as $file) { if (empty($file)) { continue; } if ($file->getPHID() == $old_phid) { $old = $file; } else if ($file->getPHID() == $new_phid) { $new = $file; } } } 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, $depths) = $this->calculateGapsMaskAndDepths( $mask_force, $feedback_mask, $range_start, $range_len ); $renderer ->setOldLines($this->old) ->setNewLines($this->new) ->setGaps($gaps) ->setMask($mask) ->setDepths($depths); $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 sparesely populated dictionary * of $visible_line_number => true. * * Depths - compute how indented any given line is. The $depths returned * is a sparesely populated dictionary of $visible_line_number => $depth. * * This function also has the side effect of modifying member variable * new such that tabs are normalized to spaces for each line of the diff. * * @return array($gaps, $mask, $depths) */ private function calculateGapsMaskAndDepths($mask_force, $feedback_mask, $range_start, $range_len) { // Calculate gaps and mask first $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 <= self::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; // Time to calculate depth. // We need to go backwards to properly indent whitespace in this code: // // 0: class C { // 1: // 1: function f() { // 2: // 2: return; // 3: // 3: } // 4: // 4: } // $depths = array(); $last_depth = 0; $range_end = $range_start + $range_len; if (!isset($this->new[$range_end])) { $range_end--; } for ($ii = $range_end; $ii >= $range_start; $ii--) { // We need to expand tabs to process mixed indenting and to round // correctly later. $line = str_replace("\t", " ", $this->new[$ii]['text']); $trimmed = ltrim($line); if ($trimmed != '') { // We round down to flatten "/**" and " *". $last_depth = floor((strlen($line) - strlen($trimmed)) / 2); } $depths[$ii] = $last_depth; } return array($gaps, $mask, $depths); } /** * 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("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 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 = '-'; $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))); } public function detectCopiedCode( array $changesets, $min_width = 30, $min_lines = 3) { assert_instances_of($changesets, 'DifferentialChangeset'); $map = array(); $files = array(); $types = array(); foreach ($changesets as $changeset) { $file = $changeset->getFilename(); foreach ($changeset->getHunks() as $hunk) { $line = $hunk->getOldOffset(); foreach (explode("\n", $hunk->getChanges()) as $code) { $type = (isset($code[0]) ? $code[0] : ''); if ($type == '-' || $type == ' ') { $code = trim(substr($code, 1)); $files[$file][$line] = $code; $types[$file][$line] = $type; if (strlen($code) >= $min_width) { $map[$code][] = array($file, $line); } $line++; } } } } foreach ($changesets as $changeset) { $copies = array(); foreach ($changeset->getHunks() as $hunk) { $added = array_map('trim', $hunk->getAddedLines()); for (reset($added); list($line, $code) = each($added); ) { if (isset($map[$code])) { // We found a long matching line. $best_length = 0; foreach ($map[$code] as $val) { // Explore all candidates. list($file, $orig_line) = $val; $length = 1; // Search also backwards for short lines. foreach (array(-1, 1) as $direction) { $offset = $direction; while (!isset($copies[$line + $offset]) && isset($added[$line + $offset]) && idx($files[$file], $orig_line + $offset) === $added[$line + $offset]) { $length++; $offset += $direction; } } if ($length > $best_length || ($length == $best_length && // Prefer moves. idx($types[$file], $orig_line) == '-')) { $best_length = $length; // ($offset - 1) contains number of forward matching lines. $best_offset = $offset - 1; $best_file = $file; $best_line = $orig_line; } } $file = ($best_file == $changeset->getFilename() ? '' : $best_file); for ($i = $best_length; $i--; ) { $type = idx($types[$best_file], $best_line + $best_offset - $i); $copies[$line + $best_offset - $i] = ($best_length < $min_lines ? array() // Ignore short blocks. : array($file, $best_line + $best_offset - $i, $type)); } for ($i = 0; $i < $best_offset; $i++) { next($added); } } } } $copies = array_filter($copies); if ($copies) { $metadata = $changeset->getMetadata(); $metadata['copy:lines'] = $copies; $changeset->setMetadata($metadata); } } return $changesets; } } diff --git a/src/applications/differential/parser/DifferentialHunkParser.php b/src/applications/differential/parser/DifferentialHunkParser.php index 424c6cfbf..5d2f9a622 100644 --- a/src/applications/differential/parser/DifferentialHunkParser.php +++ b/src/applications/differential/parser/DifferentialHunkParser.php @@ -1,650 +1,652 @@ Map of lines where hunks start, other than line 1. */ public function getHunkStartLines(array $hunks) { assert_instances_of($hunks, 'DifferentialHunk'); $map = array(); foreach ($hunks as $hunk) { $line = $hunk->getOldOffset(); if ($line > 1) { $map[$line] = true; } } return $map; } private function setVisibleLinesMask($mask) { $this->visibleLinesMask = $mask; return $this; } public function getVisibleLinesMask() { if ($this->visibleLinesMask === null) { throw new Exception( 'You must generateVisibileLinesMask before accessing this data.' ); } return $this->visibleLinesMask; } private function setIntraLineDiffs($intra_line_diffs) { $this->intraLineDiffs = $intra_line_diffs; return $this; } public function getIntraLineDiffs() { if ($this->intraLineDiffs === null) { throw new Exception( 'You must generateIntraLineDiffs before accessing this data.' ); } return $this->intraLineDiffs; } private function setNewLines($new_lines) { $this->newLines = $new_lines; return $this; } public function getNewLines() { if ($this->newLines === null) { throw new Exception( 'You must parseHunksForLineData before accessing this data.' ); } return $this->newLines; } private function setOldLines($old_lines) { $this->oldLines = $old_lines; return $this; } public function getOldLines() { if ($this->oldLines === null) { throw new Exception( 'You must parseHunksForLineData before accessing this data.' ); } return $this->oldLines; } public function getOldLineTypeMap() { $map = array(); $old = $this->getOldLines(); foreach ($old as $o) { if (!$o) { continue; } $map[$o['line']] = $o['type']; } return $map; } public function setOldLineTypeMap(array $map) { $lines = $this->getOldLines(); foreach ($lines as $key => $data) { $lines[$key]['type'] = $map[$data['line']]; } $this->oldLines = $lines; return $this; } public function getNewLineTypeMap() { $map = array(); $new = $this->getNewLines(); foreach ($new as $n) { if (!$n) { continue; } $map[$n['line']] = $n['type']; } return $map; } public function setNewLineTypeMap(array $map) { $lines = $this->getNewLines(); foreach ($lines as $key => $data) { $lines[$key]['type'] = $map[$data['line']]; } $this->newLines = $lines; return $this; } public function setWhitespaceMode($white_space_mode) { $this->whitespaceMode = $white_space_mode; return $this; } private function getWhitespaceMode() { if ($this->whitespaceMode === null) { throw new Exception( 'You must setWhitespaceMode before accessing this data.' ); } return $this->whitespaceMode; } public function getIsDeleted() { foreach ($this->getNewLines() as $line) { if ($line) { // At least one new line, so the entire file wasn't deleted. return false; } } foreach ($this->getOldLines() as $line) { if ($line) { // No new lines, at least one old line; the entire file was deleted. return true; } } // This is an empty file. return false; } /** * Returns true if the hunks change any text, not just whitespace. */ public function getHasTextChanges() { return $this->getHasChanges('text'); } /** * Returns true if the hunks change anything, including whitespace. */ public function getHasAnyChanges() { return $this->getHasChanges('any'); } private function getHasChanges($filter) { if ($filter !== 'any' && $filter !== 'text') { throw new Exception("Unknown change filter '{$filter}'."); } $old = $this->getOldLines(); $new = $this->getNewLines(); $is_any = ($filter === 'any'); foreach ($old as $key => $o) { $n = $new[$key]; if ($o === null || $n === null) { // One side is missing, and it's impossible for both sides to be null, // so the other side must have something, and thus the two sides are // different and the file has been changed under any type of filter. return true; } if ($o['type'] !== $n['type']) { // The types are different, so either the underlying text is actually // different or whatever whitespace rules we're using consider them // different. return true; } if ($o['text'] !== $n['text']) { if ($is_any) { // The text is different, so there's a change. return true; } else if (trim($o['text']) !== trim($n['text'])) { return true; } } } // No changes anywhere in the file. return false; } /** * This function takes advantage of the parsing work done in * @{method:parseHunksForLineData} and continues the struggle to hammer this * data into something we can display to a user. * * In particular, this function re-parses the hunks to make them equivalent * in length for easy rendering, adding `null` as necessary to pad the * length. * * Anyhoo, this function is not particularly well-named but I try. * * NOTE: this function must be called after * @{method:parseHunksForLineData}. */ public function reparseHunksForSpecialAttributes() { $rebuild_old = array(); $rebuild_new = array(); $old_lines = array_reverse($this->getOldLines()); $new_lines = array_reverse($this->getNewLines()); while (count($old_lines) || count($new_lines)) { $old_line_data = array_pop($old_lines); $new_line_data = array_pop($new_lines); if ($old_line_data) { $o_type = $old_line_data['type']; } else { $o_type = null; } if ($new_line_data) { $n_type = $new_line_data['type']; } else { $n_type = null; } + // This line does not exist in the new file. if (($o_type != null) && ($n_type == null)) { $rebuild_old[] = $old_line_data; $rebuild_new[] = null; if ($new_line_data) { array_push($new_lines, $new_line_data); } continue; } + // This line does not exist in the old file. if (($n_type != null) && ($o_type == null)) { $rebuild_old[] = null; $rebuild_new[] = $new_line_data; if ($old_line_data) { array_push($old_lines, $old_line_data); } continue; } $rebuild_old[] = $old_line_data; $rebuild_new[] = $new_line_data; } $this->setOldLines($rebuild_old); $this->setNewLines($rebuild_new); $this->updateChangeTypesForWhitespaceMode(); return $this; } private function updateChangeTypesForWhitespaceMode() { $mode = $this->getWhitespaceMode(); $mode_show_all = DifferentialChangesetParser::WHITESPACE_SHOW_ALL; if ($mode === $mode_show_all) { // If we're showing all whitespace, we don't need to perform any updates. return; } $mode_trailing = DifferentialChangesetParser::WHITESPACE_IGNORE_TRAILING; $is_trailing = ($mode === $mode_trailing); $new = $this->getNewLines(); $old = $this->getOldLines(); foreach ($old as $key => $o) { $n = $new[$key]; if (!$o || !$n) { continue; } if ($is_trailing) { // In "trailing" mode, we need to identify lines which are marked // changed but differ only by trailing whitespace. We mark these lines // unchanged. if ($o['type'] != $n['type']) { if (rtrim($o['text']) === rtrim($n['text'])) { $old[$key]['type'] = null; $new[$key]['type'] = null; } } } else { // In "ignore most" and "ignore all" modes, we need to identify lines // which are marked unchanged but have internal whitespace changes. // We want to ignore leading and trailing whitespace changes only, not // internal whitespace changes (`diff` doesn't have a mode for this, so // we have to fix it here). If the text is marked unchanged but the // old and new text differs by internal space, mark the lines changed. if ($o['type'] === null && $n['type'] === null) { if ($o['text'] !== $n['text']) { if (trim($o['text']) !== trim($n['text'])) { $old[$key]['type'] = '-'; $new[$key]['type'] = '+'; } } } } } $this->setOldLines($old); $this->setNewLines($new); return $this; } public function generateIntraLineDiffs() { $old = $this->getOldLines(); $new = $this->getNewLines(); $diffs = array(); foreach ($old as $key => $o) { $n = $new[$key]; if (!$o || !$n) { continue; } if ($o['type'] != $n['type']) { $diffs[$key] = ArcanistDiffUtils::generateIntralineDiff( $o['text'], $n['text']); } } $this->setIntraLineDiffs($diffs); return $this; } public function generateVisibileLinesMask() { $lines_context = DifferentialChangesetParser::LINES_CONTEXT; $old = $this->getOldLines(); $new = $this->getNewLines(); $max_length = max(count($old), count($new)); $visible = false; $last = 0; $mask = array(); for ($cursor = -$lines_context; $cursor < $max_length; $cursor++) { $offset = $cursor + $lines_context; if ((isset($old[$offset]) && $old[$offset]['type']) || (isset($new[$offset]) && $new[$offset]['type'])) { $visible = true; $last = $offset; } else if ($cursor > $last + $lines_context) { $visible = false; } if ($visible && $cursor > 0) { $mask[$cursor] = 1; } } $this->setVisibleLinesMask($mask); return $this; } public function getOldCorpus() { return $this->getCorpus($this->getOldLines()); } public function getNewCorpus() { return $this->getCorpus($this->getNewLines()); } private function getCorpus(array $lines) { $corpus = array(); foreach ($lines as $l) { if ($l['type'] != '\\') { if ($l['text'] === null) { // There's no text on this side of the diff, but insert a placeholder // newline so the highlighted line numbers match up. $corpus[] = "\n"; } else { $corpus[] = $l['text']; } } } return $corpus; } public function parseHunksForLineData(array $hunks) { assert_instances_of($hunks, 'DifferentialHunk'); $old_lines = array(); $new_lines = array(); foreach ($hunks as $hunk) { $lines = $hunk->getChanges(); $lines = phutil_split_lines($lines); $line_type_map = array(); foreach ($lines as $line_index => $line) { if (isset($line[0])) { $char = $line[0]; if ($char == ' ') { $line_type_map[$line_index] = null; } else { $line_type_map[$line_index] = $char; } } else { $line_type_map[$line_index] = null; } } $old_line = $hunk->getOldOffset(); $new_line = $hunk->getNewOffset(); $num_lines = count($lines); for ($cursor = 0; $cursor < $num_lines; $cursor++) { $type = $line_type_map[$cursor]; $data = array( 'type' => $type, 'text' => (string)substr($lines[$cursor], 1), 'line' => $new_line, ); if ($type == '\\') { $type = $line_type_map[$cursor - 1]; $data['text'] = ltrim($data['text']); } switch ($type) { case '+': $new_lines[] = $data; ++$new_line; break; case '-': $data['line'] = $old_line; $old_lines[] = $data; ++$old_line; break; default: $new_lines[] = $data; $data['line'] = $old_line; $old_lines[] = $data; ++$new_line; ++$old_line; break; } } } $this->setOldLines($old_lines); $this->setNewLines($new_lines); return $this; } public function parseHunksForHighlightMasks( array $changeset_hunks, array $old_hunks, array $new_hunks) { assert_instances_of($changeset_hunks, 'DifferentialHunk'); assert_instances_of($old_hunks, 'DifferentialHunk'); assert_instances_of($new_hunks, 'DifferentialHunk'); // Put changes side by side. $olds = array(); $news = array(); foreach ($changeset_hunks as $hunk) { $n_old = $hunk->getOldOffset(); $n_new = $hunk->getNewOffset(); $changes = phutil_split_lines($hunk->getChanges()); foreach ($changes as $line) { $diff_type = $line[0]; // Change type in diff of diffs. $orig_type = $line[1]; // Change type in the original diff. if ($diff_type == ' ') { // Use the same key for lines that are next to each other. $key = max(last_key($olds), last_key($news)) + 1; $olds[$key] = null; $news[$key] = null; } else if ($diff_type == '-') { $olds[] = array($n_old, $orig_type); } else if ($diff_type == '+') { $news[] = array($n_new, $orig_type); } if (($diff_type == '-' || $diff_type == ' ') && $orig_type != '-') { $n_old++; } if (($diff_type == '+' || $diff_type == ' ') && $orig_type != '-') { $n_new++; } } } $offsets_old = $this->computeOffsets($old_hunks); $offsets_new = $this->computeOffsets($new_hunks); // Highlight lines that were added on each side or removed on the other // side. $highlight_old = array(); $highlight_new = array(); $last = max(last_key($olds), last_key($news)); for ($i = 0; $i <= $last; $i++) { if (isset($olds[$i])) { list($n, $type) = $olds[$i]; if ($type == '+' || ($type == ' ' && isset($news[$i]) && $news[$i][1] != ' ')) { $highlight_old[] = $offsets_old[$n]; } } if (isset($news[$i])) { list($n, $type) = $news[$i]; if ($type == '+' || ($type == ' ' && isset($olds[$i]) && $olds[$i][1] != ' ')) { $highlight_new[] = $offsets_new[$n]; } } } return array($highlight_old, $highlight_new); } public function makeContextDiff( array $hunks, PhabricatorInlineCommentInterface $inline, $add_context) { assert_instances_of($hunks, 'DifferentialHunk'); $context = array(); $debug = false; if ($debug) { $context[] = 'Inline: '.$inline->getIsNewFile().' '. $inline->getLineNumber().' '.$inline->getLineLength(); foreach ($hunks as $hunk) { $context[] = 'hunk: '.$hunk->getOldOffset().'-'. $hunk->getOldLen().'; '.$hunk->getNewOffset().'-'.$hunk->getNewLen(); $context[] = $hunk->getChanges(); } } if ($inline->getIsNewFile()) { $prefix = '+'; } else { $prefix = '-'; } foreach ($hunks as $hunk) { if ($inline->getIsNewFile()) { $offset = $hunk->getNewOffset(); $length = $hunk->getNewLen(); } else { $offset = $hunk->getOldOffset(); $length = $hunk->getOldLen(); } $start = $inline->getLineNumber() - $offset; $end = $start + $inline->getLineLength(); // We need to go in if $start == $length, because the last line // might be a "\No newline at end of file" marker, which we want // to show if the additional context is > 0. if ($start <= $length && $end >= 0) { $start = $start - $add_context; $end = $end + $add_context; $hunk_content = array(); $hunk_pos = array( "-" => 0, "+" => 0 ); $hunk_offset = array( "-" => NULL, "+" => NULL ); $hunk_last = array( "-" => NULL, "+" => NULL ); foreach (explode("\n", $hunk->getChanges()) as $line) { $in_common = strncmp($line, " ", 1) === 0; $in_old = strncmp($line, "-", 1) === 0 || $in_common; $in_new = strncmp($line, "+", 1) === 0 || $in_common; $in_selected = strncmp($line, $prefix, 1) === 0; $skip = !$in_selected && !$in_common; if ($hunk_pos[$prefix] <= $end) { if ($start <= $hunk_pos[$prefix]) { if (!$skip || ($hunk_pos[$prefix] != $start && $hunk_pos[$prefix] != $end)) { if ($in_old) { if ($hunk_offset["-"] === NULL) { $hunk_offset["-"] = $hunk_pos["-"]; } $hunk_last["-"] = $hunk_pos["-"]; } if ($in_new) { if ($hunk_offset["+"] === NULL) { $hunk_offset["+"] = $hunk_pos["+"]; } $hunk_last["+"] = $hunk_pos["+"]; } $hunk_content[] = $line; } } if ($in_old) { ++$hunk_pos["-"]; } if ($in_new) { ++$hunk_pos["+"]; } } } if ($hunk_offset["-"] !== NULL || $hunk_offset["+"] !== NULL) { $header = "@@"; if ($hunk_offset["-"] !== NULL) { $header .= " -" . ($hunk->getOldOffset() + $hunk_offset["-"]) . "," . ($hunk_last["-"] - $hunk_offset["-"] + 1); } if ($hunk_offset["+"] !== NULL) { $header .= " +" . ($hunk->getNewOffset() + $hunk_offset["+"]) . "," . ($hunk_last["+"] - $hunk_offset["+"] + 1); } $header .= " @@"; $context[] = $header; $context[] = implode("\n", $hunk_content); } } } return implode("\n", $context); } private function computeOffsets(array $hunks) { assert_instances_of($hunks, 'DifferentialHunk'); $offsets = array(); $n = 1; foreach ($hunks as $hunk) { for ($i = 0; $i < $hunk->getNewLen(); $i++) { $offsets[$n] = $hunk->getNewOffset() + $i; $n++; } } return $offsets; } } diff --git a/src/applications/differential/render/DifferentialChangesetRenderer.php b/src/applications/differential/render/DifferentialChangesetRenderer.php index 9d7671bbc..157aff5f9 100644 --- a/src/applications/differential/render/DifferentialChangesetRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetRenderer.php @@ -1,607 +1,648 @@ lineCount = $line_count; return $this; } public function getLineCount() { return $this->lineCount; } public function setDepths($depths) { $this->depths = $depths; return $this; } protected function getDepths() { return $this->depths; } public function setMask($mask) { $this->mask = $mask; return $this; } protected function getMask() { return $this->mask; } public function setGaps($gaps) { $this->gaps = $gaps; return $this; } protected function getGaps() { return $this->gaps; } public function setOriginalNew($original_new) { $this->originalNew = $original_new; return $this; } protected function getOriginalNew() { return $this->originalNew; } public function setOriginalOld($original_old) { $this->originalOld = $original_old; return $this; } protected function getOriginalOld() { return $this->originalOld; } public function setNewRender($new_render) { $this->newRender = $new_render; return $this; } protected function getNewRender() { return $this->newRender; } public function setOldRender($old_render) { $this->oldRender = $old_render; return $this; } protected function getOldRender() { return $this->oldRender; } public function setMarkupEngine(PhabricatorMarkupEngine $markup_engine) { $this->markupEngine = $markup_engine; return $this; } public function getMarkupEngine() { return $this->markupEngine; } public function setHandles(array $handles) { assert_instances_of($handles, 'PhabricatorObjectHandle'); $this->handles = $handles; return $this; } protected function getHandles() { return $this->handles; } public function setCodeCoverage($code_coverage) { $this->codeCoverage = $code_coverage; return $this; } protected function getCodeCoverage() { return $this->codeCoverage; } public function setHighlightNew($highlight_new) { $this->highlightNew = $highlight_new; return $this; } protected function getHighlightNew() { return $this->highlightNew; } public function setHighlightOld($highlight_old) { $this->highlightOld = $highlight_old; return $this; } protected function getHighlightOld() { return $this->highlightOld; } public function setNewAttachesToNewFile($attaches) { $this->newAttachesToNewFile = $attaches; return $this; } protected function getNewAttachesToNewFile() { return $this->newAttachesToNewFile; } public function setOldAttachesToNewFile($attaches) { $this->oldAttachesToNewFile = $attaches; return $this; } protected function getOldAttachesToNewFile() { return $this->oldAttachesToNewFile; } public function setNewChangesetID($new_changeset_id) { $this->newChangesetID = $new_changeset_id; return $this; } protected function getNewChangesetID() { return $this->newChangesetID; } public function setOldChangesetID($old_changeset_id) { $this->oldChangesetID = $old_changeset_id; return $this; } protected function getOldChangesetID() { return $this->oldChangesetID; } public function setNewComments(array $new_comments) { foreach ($new_comments as $line_number => $comments) { assert_instances_of($comments, 'PhabricatorInlineCommentInterface'); } $this->newComments = $new_comments; return $this; } protected function getNewComments() { return $this->newComments; } public function setOldComments(array $old_comments) { foreach ($old_comments as $line_number => $comments) { assert_instances_of($comments, 'PhabricatorInlineCommentInterface'); } $this->oldComments = $old_comments; return $this; } protected function getOldComments() { return $this->oldComments; } public function setNewLines(array $new_lines) { $this->newLines = $new_lines; return $this; } protected function getNewLines() { return $this->newLines; } public function setOldLines(array $old_lines) { $this->oldLines = $old_lines; return $this; } protected function getOldLines() { return $this->oldLines; } public function setHunkStartLines(array $hunk_start_lines) { $this->hunkStartLines = $hunk_start_lines; return $this; } protected function getHunkStartLines() { return $this->hunkStartLines; } public function setUser(PhabricatorUser $user) { $this->user = $user; return $this; } protected function getUser() { return $this->user; } public function setChangeset(DifferentialChangeset $changeset) { $this->changeset = $changeset; return $this; } protected function getChangeset() { return $this->changeset; } public function setRenderingReference($rendering_reference) { $this->renderingReference = $rendering_reference; return $this; } protected function getRenderingReference() { return $this->renderingReference; } public function setRenderPropertyChangeHeader($should_render) { $this->renderPropertyChangeHeader = $should_render; return $this; } private function shouldRenderPropertyChangeHeader() { return $this->renderPropertyChangeHeader; } abstract public function renderChangesetTable($contents); abstract public function renderTextChange( $range_start, $range_len, $rows ); abstract public function renderFileChange( $old = null, $new = null, $id = 0, $vs = 0 ); - public function renderShield($message, $more) { - - if ($more) { - $end = $this->getLineCount(); - $reference = $this->getRenderingReference(); - $more = - ' '. - javelin_render_tag( - 'a', - array( - 'mustcapture' => true, - 'sigil' => 'show-more', - 'class' => 'complete', - 'href' => '#', - 'meta' => array( - 'ref' => $reference, - 'range' => "0-{$end}", - ), - ), - 'Show File Contents'); - } else { - $more = null; + /** + * Render a "shield" over the diff, with a message like "This file is + * generated and does not need to be reviewed." or "This file was completely + * deleted." This UI element hides unimportant text so the reviewer doesn't + * need to scroll past it. + * + * The shield includes a link to view the underlying content. This link + * may force certain rendering modes when the link is clicked: + * + * - `"default"`: Render the diff normally, as though it was not + * shielded. This is the default and appropriate if the underlying + * diff is a normal change, but was hidden for reasons of not being + * important (e.g., generated code). + * - `"text"`: Force the text to be shown. This is probably only relevant + * when a file is not changed. + * - `"whitespace"`: Force the text to be shown, and the diff to be + * rendered with all whitespace shown. This is probably only relevant + * when a file is changed only by altering whitespace. + * - `"none"`: Don't show the link (e.g., text not available). + * + * @param string Message explaining why the diff is hidden. + * @param string|null Force mode, see above. + * @return string Shield markup. + */ + public function renderShield($message, $force = 'default') { + + $end = $this->getLineCount(); + $reference = $this->getRenderingReference(); + + if ($force !== 'text' && + $force !== 'whitespace' && + $force !== 'none' && + $force !== 'default') { + throw new Exception("Invalid 'force' parameter '{$force}'!"); + } + + $range = "0-{$end}"; + if ($force == 'text') { + // If we're forcing text, force the whole file to be rendered. + $range = "{$range}/0-{$end}"; + } + + $meta = array( + 'ref' => $reference, + 'range' => $range, + ); + + if ($force == 'whitespace') { + $meta['whitespace'] = DifferentialChangesetParser::WHITESPACE_SHOW_ALL; + } + + $more = null; + if ($force !== 'none') { + $more = ' '.javelin_render_tag( + 'a', + array( + 'mustcapture' => true, + 'sigil' => 'show-more', + 'class' => 'complete', + 'href' => '#', + 'meta' => $meta, + ), + 'Show File Contents'); } return javelin_render_tag( 'tr', array( 'sigil' => 'context-target', ), ''. phutil_escape_html($message). $more. ''); } protected function renderPropertyChangeHeader($changeset) { if (!$this->shouldRenderPropertyChangeHeader()) { return null; } $old = $changeset->getOldProperties(); $new = $changeset->getNewProperties(); $keys = array_keys($old + $new); sort($keys); $rows = array(); foreach ($keys as $key) { $oval = idx($old, $key); $nval = idx($new, $key); if ($oval !== $nval) { if ($oval === null) { $oval = 'null'; } else { $oval = nl2br(phutil_escape_html($oval)); } if ($nval === null) { $nval = 'null'; } else { $nval = nl2br(phutil_escape_html($nval)); } $rows[] = ''. ''.phutil_escape_html($key).''. ''.$oval.''. ''.$nval.''. ''; } } return ''. ''. ''. ''. ''. ''. implode('', $rows). '
Property ChangesOld ValueNew Value
'; } protected function renderChangeTypeHeader($changeset, $force) { $change = $changeset->getChangeType(); $file = $changeset->getFileType(); $message = null; if ($change == DifferentialChangeType::TYPE_CHANGE && $file == DifferentialChangeType::FILE_TEXT) { if ($force) { // We have to force something to render because there were no changes // of other kinds. $message = pht('This file was not modified.'); } else { // Default case of changes to a text file, no metadata. return null; } } else { switch ($change) { case DifferentialChangeType::TYPE_ADD: switch ($file) { case DifferentialChangeType::FILE_TEXT: $message = pht('This file was added.'); break; case DifferentialChangeType::FILE_IMAGE: $message = pht('This image was added.'); break; case DifferentialChangeType::FILE_DIRECTORY: $message = pht('This directory was added.'); break; case DifferentialChangeType::FILE_BINARY: $message = pht('This binary file was added.'); break; case DifferentialChangeType::FILE_SYMLINK: $message = pht('This symlink was added.'); break; case DifferentialChangeType::FILE_SUBMODULE: $message = pht('This submodule was added.'); break; } break; case DifferentialChangeType::TYPE_DELETE: switch ($file) { case DifferentialChangeType::FILE_TEXT: $message = pht('This file was deleted.'); break; case DifferentialChangeType::FILE_IMAGE: $message = pht('This image was deleted.'); break; case DifferentialChangeType::FILE_DIRECTORY: $message = pht('This directory was deleted.'); break; case DifferentialChangeType::FILE_BINARY: $message = pht('This binary file was deleted.'); break; case DifferentialChangeType::FILE_SYMLINK: $message = pht('This symlink was deleted.'); break; case DifferentialChangeType::FILE_SUBMODULE: $message = pht('This submodule was deleted.'); break; } break; case DifferentialChangeType::TYPE_MOVE_HERE: $from = "". phutil_escape_html($changeset->getOldFile()). ""; switch ($file) { case DifferentialChangeType::FILE_TEXT: $message = pht('This file was moved from %s.', $from); break; case DifferentialChangeType::FILE_IMAGE: $message = pht('This image was moved from %s.', $from); break; case DifferentialChangeType::FILE_DIRECTORY: $message = pht('This directory was moved from %s.', $from); break; case DifferentialChangeType::FILE_BINARY: $message = pht('This binary file was moved from %s.', $from); break; case DifferentialChangeType::FILE_SYMLINK: $message = pht('This symlink was moved from %s.', $from); break; case DifferentialChangeType::FILE_SUBMODULE: $message = pht('This submodule was moved from %s.', $from); break; } break; case DifferentialChangeType::TYPE_COPY_HERE: $from = "". phutil_escape_html($changeset->getOldFile()). ""; switch ($file) { case DifferentialChangeType::FILE_TEXT: $message = pht('This file was copied from %s.', $from); break; case DifferentialChangeType::FILE_IMAGE: $message = pht('This image was copied from %s.', $from); break; case DifferentialChangeType::FILE_DIRECTORY: $message = pht('This directory was copied from %s.', $from); break; case DifferentialChangeType::FILE_BINARY: $message = pht('This binary file was copied from %s.', $from); break; case DifferentialChangeType::FILE_SYMLINK: $message = pht('This symlink was copied from %s.', $from); break; case DifferentialChangeType::FILE_SUBMODULE: $message = pht('This submodule was copied from %s.', $from); break; } break; case DifferentialChangeType::TYPE_MOVE_AWAY: $paths = "". phutil_escape_html(implode(', ', $changeset->getAwayPaths())). ""; switch ($file) { case DifferentialChangeType::FILE_TEXT: $message = pht('This file was moved to %s.', $paths); break; case DifferentialChangeType::FILE_IMAGE: $message = pht('This image was moved to %s.', $paths); break; case DifferentialChangeType::FILE_DIRECTORY: $message = pht('This directory was moved to %s.', $paths); break; case DifferentialChangeType::FILE_BINARY: $message = pht('This binary file was moved to %s.', $paths); break; case DifferentialChangeType::FILE_SYMLINK: $message = pht('This symlink was moved to %s.', $paths); break; case DifferentialChangeType::FILE_SUBMODULE: $message = pht('This submodule was moved to %s.', $paths); break; } break; case DifferentialChangeType::TYPE_COPY_AWAY: $paths = "". phutil_escape_html(implode(', ', $changeset->getAwayPaths())). ""; switch ($file) { case DifferentialChangeType::FILE_TEXT: $message = pht('This file was copied to %s.', $paths); break; case DifferentialChangeType::FILE_IMAGE: $message = pht('This image was copied to %s.', $paths); break; case DifferentialChangeType::FILE_DIRECTORY: $message = pht('This directory was copied to %s.', $paths); break; case DifferentialChangeType::FILE_BINARY: $message = pht('This binary file was copied to %s.', $paths); break; case DifferentialChangeType::FILE_SYMLINK: $message = pht('This symlink was copied to %s.', $paths); break; case DifferentialChangeType::FILE_SUBMODULE: $message = pht('This submodule was copied to %s.', $paths); break; } break; case DifferentialChangeType::TYPE_MULTICOPY: $paths = "". phutil_escape_html(implode(', ', $changeset->getAwayPaths())). ""; switch ($file) { case DifferentialChangeType::FILE_TEXT: $message = pht( 'This file was deleted after being copied to %s.', $paths); break; case DifferentialChangeType::FILE_IMAGE: $message = pht( 'This image was deleted after being copied to %s.', $paths); break; case DifferentialChangeType::FILE_DIRECTORY: $message = pht( 'This directory was deleted after being copied to %s.', $paths); break; case DifferentialChangeType::FILE_BINARY: $message = pht( 'This binary file was deleted after being copied to %s.', $paths); break; case DifferentialChangeType::FILE_SYMLINK: $message = pht( 'This symlink was deleted after being copied to %s.', $paths); break; case DifferentialChangeType::FILE_SUBMODULE: $message = pht( 'This submodule was deleted after being copied to %s.', $paths); break; } break; default: switch ($file) { case DifferentialChangeType::FILE_TEXT: $message = pht('This is a file.'); break; case DifferentialChangeType::FILE_IMAGE: $message = pht('This is an image.'); break; case DifferentialChangeType::FILE_DIRECTORY: $message = pht('This is a directory.'); break; case DifferentialChangeType::FILE_BINARY: $message = pht('This is a binary file.'); break; case DifferentialChangeType::FILE_SYMLINK: $message = pht('This is a symlink.'); break; case DifferentialChangeType::FILE_SUBMODULE: $message = pht('This is a submodule.'); break; } break; } } return '
'. $message. '
'; } protected function renderInlineComment( PhabricatorInlineCommentInterface $comment, $on_right = false) { $user = $this->getUser(); $edit = $user && ($comment->getAuthorPHID() == $user->getPHID()) && ($comment->isDraft()); $allow_reply = (bool)$user; return id(new DifferentialInlineCommentView()) ->setInlineComment($comment) ->setOnRight($on_right) ->setHandles($this->getHandles()) ->setMarkupEngine($this->getMarkupEngine()) ->setEditable($edit) ->setAllowReply($allow_reply) ->render(); } } diff --git a/webroot/rsrc/js/application/differential/behavior-show-more.js b/webroot/rsrc/js/application/differential/behavior-show-more.js index b38f5062b..2c44b798c 100644 --- a/webroot/rsrc/js/application/differential/behavior-show-more.js +++ b/webroot/rsrc/js/application/differential/behavior-show-more.js @@ -1,60 +1,64 @@ /** * @provides javelin-behavior-differential-show-more * @requires javelin-behavior * javelin-dom * javelin-workflow * javelin-util * javelin-stratcom */ JX.behavior('differential-show-more', function(config) { function onresponse(context, response) { var div = JX.$N('div', {}, JX.$H(response.changeset)); var root = context.parentNode; copyRows(root, div, context); root.removeChild(context); } JX.Stratcom.listen( 'click', 'show-more', function(e) { var event_data = { context : e.getNodes()['context-target'], show : e.getNodes()['show-more'] }; JX.Stratcom.invoke('differential-reveal-context', null, event_data); e.kill(); }); JX.Stratcom.listen( 'differential-reveal-context', null, function(e) { var context = e.getData().context; var data = JX.Stratcom.getData(e.getData().show); var container = JX.DOM.scry(context, 'td')[0]; JX.DOM.setContent(container, 'Loading...'); JX.DOM.alterClass(context, 'differential-show-more-loading', true); - data['whitespace'] = config.whitespace; + + if (!data['whitespace']) { + data['whitespace'] = config.whitespace; + } + new JX.Workflow(config.uri, data) .setHandler(JX.bind(null, onresponse, context)) .start(); }); }); function copyRows(dst, src, before) { var rows = JX.DOM.scry(src, 'tr'); for (var ii = 0; ii < rows.length; ii++) { if (before) { dst.insertBefore(rows[ii], before); } else { dst.appendChild(rows[ii]); } } return rows; }