diff --git a/src/applications/differential/controller/DifferentialChangesetViewController.php b/src/applications/differential/controller/DifferentialChangesetViewController.php index 060892436e..aab16791ca 100644 --- a/src/applications/differential/controller/DifferentialChangesetViewController.php +++ b/src/applications/differential/controller/DifferentialChangesetViewController.php @@ -1,381 +1,362 @@ getRequest(); $author_phid = $request->getUser()->getPHID(); $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($request->getUser()) ->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(); } } 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(); } 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; } else { $right = $changeset; $left = $vs_changeset; $right_source = $right->getID(); $right_new = true; $left_source = $left->getID(); $left_new = true; $render_cache_key = null; } if ($left) { $left_data = $left->makeNewFile(); if ($right) { $right_data = $right->makeNewFile(); } else { $right_data = $left->makeOldFile(); } $engine = new PhabricatorDifferenceEngine(); $synthetic = $engine->generateChangesetFromFileContent( $left_data, $right_data); $choice = clone nonempty($left, $right); $choice->attachHunks($synthetic->getHunks()); $changeset = $choice; } $coverage = null; if ($right && $right->getDiffID()) { $unit = id(new DifferentialDiffProperty())->loadOneWhere( 'diffID = %d AND name = %s', $right->getDiffID(), 'arc:unit'); if ($unit) { $coverage = array(); foreach ($unit->getData() as $result) { $result_coverage = idx($result, 'coverage'); if (!$result_coverage) { continue; } $file_coverage = idx($result_coverage, $right->getFileName()); if (!$file_coverage) { continue; } $coverage[] = $file_coverage; } $coverage = ArcanistUnitTestResult::mergeCoverage($coverage); } } $spec = $request->getStr('range'); list($range_s, $range_e, $mask) = DifferentialChangesetParser::parseRangeSpecification($spec); - $parser = new DifferentialChangesetParser(); - $parser->setCoverage($coverage); - $parser->setChangeset($changeset); - $parser->setRenderingReference($rendering_reference); - $parser->setRenderCacheKey($render_cache_key); - $parser->setRightSideCommentMapping($right_source, $right_new); - $parser->setLeftSideCommentMapping($left_source, $left_new); + $parser = id(new DifferentialChangesetParser()) + ->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); } // Load both left-side and right-side inline comments. $inlines = $this->loadInlineComments( array($left_source, $right_source), $author_phid); 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($request->getUser()); foreach ($inlines as $inline) { $engine->addObject( $inline, PhabricatorInlineCommentInterface::MARKUP_FIELD_BODY); } $engine->process(); - $parser->setMarkupEngine($engine); - $parser->setUser($request->getUser()); - - if ($request->isAjax()) { - $parser->setShowEditAndReplyLinks(true); - } else { - $parser->setShowEditAndReplyLinks(false); - } - - $output = $parser->render($range_s, $range_e, $mask); - $mcov = $parser->renderModifiedCoverage(); + $parser + ->setUser($request->getUser()) + ->setMarkupEngine($engine) + ->setShowEditAndReplyLinks(true) + ->setRange($range_s, $range_e) + ->setMask($mask); if ($request->isAjax()) { + $mcov = $parser->renderModifiedCoverage(); + $coverage = array( 'differential-mcoverage-'.md5($changeset->getFilename()) => $mcov, ); return id(new PhabricatorChangesetResponse()) - ->setRenderedChangeset($output) + ->setRenderedChangeset($parser->renderChangeset()) ->setCoverage($coverage); } - // TODO: [HTML] Clean up DifferentialChangesetParser output, but it's - // undergoing like six kinds of refactoring anyway. - $output = phutil_safe_html($output); + $diff = $changeset->getDiff(); - $detail = id(new DifferentialChangesetDetailView()) + $detail = id(new DifferentialChangesetListView()) ->setUser($this->getViewer()) - ->setChangeset($changeset) - ->setRenderingRef($rendering_reference) + ->setChangesets(array($changeset)) + ->setVisibleChangesets(array($changeset)) + ->setRenderingReferences(array($rendering_reference)) ->setRenderURI('/differential/changeset/') - ->setRenderer($parser->getRenderer()->getRendererKey()) - ->appendChild($output) - ->setVsChangesetID($left_source); + ->setDiff($diff) + ->setTitle(pht('Standalone View')) + ->setParser($parser); - Javelin::initBehavior('differential-populate', array( - 'changesetViewIDs' => array($detail->getID()), - )); - - Javelin::initBehavior('differential-comment-jump', array()); - - $panel = new DifferentialPrimaryPaneView(); - $panel->appendChild( - phutil_tag( - 'div', - array( - 'class' => 'differential-review-stage', - 'id' => 'differential-review-stage', - ), - $detail->render())); + $revision_id = $diff->getRevisionID(); + if ($revision_id) { + $detail->setInlineCommentControllerURI( + '/differential/comment/inline/edit/'.$revision_id.'/'); + } $crumbs = $this->buildApplicationCrumbs(); - $revision_id = $changeset->getDiff()->getRevisionID(); if ($revision_id) { $crumbs->addTextCrumb('D'.$revision_id, '/D'.$revision_id); } - $diff_id = $changeset->getDiff()->getID(); + $diff_id = $diff->getID(); if ($diff_id) { $crumbs->addTextCrumb( pht('Diff %d', $diff_id), $this->getApplicationURI('diff/'.$diff_id)); } $crumbs->addTextCrumb($changeset->getDisplayFilename()); - $box = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Standalone View')) - ->appendChild($panel); - return $this->buildApplicationPage( array( $crumbs, - $box, + $detail, ), array( 'title' => pht('Changeset View'), 'device' => false, )); } private function loadInlineComments(array $changeset_ids, $author_phid) { $changeset_ids = array_unique(array_filter($changeset_ids)); if (!$changeset_ids) { return; } return id(new DifferentialInlineCommentQuery()) ->withViewerAndChangesetIDs($author_phid, $changeset_ids) ->execute(); } private function buildRawFileResponse( DifferentialChangeset $changeset, $is_new) { $viewer = $this->getRequest()->getUser(); 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(); } $file = PhabricatorFile::newFromFileData( $data, array( 'name' => $changeset->getFilename(), 'mime-type' => 'text/plain', )); $metadata[$key] = $file->getPHID(); $changeset->setMetadata($metadata); $changeset->save(); unset($unguard); } return $file->getRedirectResponse(); } private function buildLintInlineComments($changeset) { $lint = id(new DifferentialDiffProperty())->loadOneWhere( 'diffID = %d AND name = %s', $changeset->getDiffID(), 'arc:lint'); if (!$lint) { return array(); } $lint = $lint->getData(); $inlines = array(); foreach ($lint as $msg) { if ($msg['path'] != $changeset->getFilename()) { continue; } $inline = new DifferentialInlineComment(); $inline->setChangesetID($changeset->getID()); $inline->setIsNewFile(1); $inline->setSyntheticAuthor('Lint: '.$msg['name']); $inline->setLineNumber($msg['line']); $inline->setLineLength(0); $inline->setContent('%%%'.$msg['description'].'%%%'); $inlines[] = $inline; } return $inlines; } } diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php index cd689153f0..39bb8ce80b 100644 --- a/src/applications/differential/parser/DifferentialChangesetParser.php +++ b/src/applications/differential/parser/DifferentialChangesetParser.php @@ -1,1346 +1,1365 @@ 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 static function getDefaultRendererForViewer(PhabricatorUser $viewer) { $prefs = $viewer->loadPreferences(); $pref_unified = PhabricatorUserPreferences::PREFERENCE_DIFF_UNIFIED; if ($prefs->getPreference($pref_unified) == 'unified') { return '1up'; } return null; } public function readParametersFromRequest(AphrontRequest $request) { $this->setWhitespaceMode($request->getStr('whitespace')); $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 = 11; const CACHE_MAX_SIZE = 8e6; const ATTR_GENERATED = 'attr:generated'; const ATTR_DELETED = 'attr:deleted'; const ATTR_UNCHANGED = 'attr:unchanged'; const ATTR_WHITELINES = 'attr:white'; const ATTR_MOVEAWAY = 'attr:moveaway'; const LINES_CONTEXT = 8; const WHITESPACE_SHOW_ALL = 'show-all'; const WHITESPACE_IGNORE_TRAILING = 'ignore-trailing'; const WHITESPACE_IGNORE_MOST = 'ignore-most'; const WHITESPACE_IGNORE_ALL = 'ignore-all'; 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 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 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', '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 = 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'); $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); } public function isMoveAway() { return idx($this->specialAttributes, self::ATTR_MOVEAWAY, 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) { $language = $this->highlightAs; if (!$language) { $language = $this->highlightEngine->getLanguageFromFilename( $this->filename); } 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() { $whitespace_mode = $this->whitespaceMode; switch ($whitespace_mode) { case self::WHITESPACE_SHOW_ALL: case self::WHITESPACE_IGNORE_TRAILING: case self::WHITESPACE_IGNORE_ALL: break; default: $whitespace_mode = self::WHITESPACE_IGNORE_MOST; break; } $skip_cache = ($whitespace_mode != self::WHITESPACE_IGNORE_MOST); if ($this->disableCache) { $skip_cache = true; } if ($this->characterEncoding) { $skip_cache = true; } if ($this->highlightAs) { $skip_cache = true; } $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_MOST) || ($whitespace_mode == self::WHITESPACE_IGNORE_ALL)); $force_ignore = ($whitespace_mode == self::WHITESPACE_IGNORE_ALL); 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; } } $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_WHITELINES => !$hunk_parser->getHasTextChanges(), self::ATTR_MOVEAWAY => $moveaway, )); $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 (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(); $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()); $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'; } $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.'), 'whitespace'); } else if ($this->isDeleted()) { $shield = $renderer->renderShield( 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)); } } if ($shield !== null) { 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); $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, $depths) = $this->calculateGapsMaskAndDepths( $mask_force, $feedback_mask, $range_start, $range_len); $renderer ->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; // 1: // 1: } // 0: // 0: } // $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 = 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))); } 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. if (count($map[$code]) > 16) { // If there are a large number of identical lines in this diff, // don't try to figure out where this block came from: the // analysis is O(N^2), since we need to compare every line // against every other line. Even if we arrive at a result, it // is unlikely to be meaningful. See T5041. continue 2; } $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/view/DifferentialChangesetDetailView.php b/src/applications/differential/view/DifferentialChangesetDetailView.php index 7723a7be7d..a6dc8a5f3a 100644 --- a/src/applications/differential/view/DifferentialChangesetDetailView.php +++ b/src/applications/differential/view/DifferentialChangesetDetailView.php @@ -1,244 +1,255 @@ autoload = $autoload; return $this; } public function getAutoload() { return $this->autoload; } + public function setLoaded($loaded) { + $this->loaded = $loaded; + return $this; + } + + public function getLoaded() { + return $this->loaded; + } + public function setRenderingRef($rendering_ref) { $this->renderingRef = $rendering_ref; return $this; } public function getRenderingRef() { return $this->renderingRef; } public function setWhitespace($whitespace) { $this->whitespace = $whitespace; return $this; } public function getWhitespace() { return $this->whitespace; } public function setRenderURI($render_uri) { $this->renderURI = $render_uri; return $this; } public function getRenderURI() { return $this->renderURI; } public function setChangeset($changeset) { $this->changeset = $changeset; return $this; } public function addButton($button) { $this->buttons[] = $button; return $this; } public function setEditable($editable) { $this->editable = $editable; return $this; } public function setSymbolIndex($symbol_index) { $this->symbolIndex = $symbol_index; return $this; } public function setRenderer($renderer) { $this->renderer = $renderer; return $this; } public function getRenderer() { return $this->renderer; } public function getID() { if (!$this->id) { $this->id = celerity_generate_unique_node_id(); } return $this->id; } public function setID($id) { $this->id = $id; return $this; } public function setVsChangesetID($vs_changeset_id) { $this->vsChangesetID = $vs_changeset_id; return $this; } public function getVsChangesetID() { return $this->vsChangesetID; } public function getFileIcon($filename) { $path_info = pathinfo($filename); $extension = idx($path_info, 'extension'); switch ($extension) { case 'psd': case 'ai': $icon = 'fa-eye'; break; case 'conf': $icon = 'fa-wrench'; break; case 'wav': case 'mp3': case 'aiff': $icon = 'fa-file-sound-o'; break; case 'm4v': case 'mov': $icon = 'fa-file-movie-o'; break; case 'sql': case 'db': $icon = 'fa-database'; break; case 'xls': case 'csv': $icon = 'fa-file-excel-o'; break; case 'ics': $icon = 'fa-calendar'; break; case 'zip': case 'tar': case 'bz': case 'tgz': case 'gz': $icon = 'fa-file-archive-o'; break; case 'png': case 'jpg': case 'bmp': case 'gif': $icon = 'fa-file-picture-o'; break; case 'txt': $icon = 'fa-file-text-o'; break; case 'doc': case 'docx': $icon = 'fa-file-word-o'; break; case 'pdf': $icon = 'fa-file-pdf-o'; break; default: $icon = 'fa-file-code-o'; break; } return $icon; } public function render() { $this->requireResource('differential-changeset-view-css'); $this->requireResource('syntax-highlighting-css'); Javelin::initBehavior('phabricator-oncopy', array()); $changeset = $this->changeset; $class = 'differential-changeset'; if (!$this->editable) { $class .= ' differential-changeset-immutable'; } $buttons = null; if ($this->buttons) { $buttons = phutil_tag( 'div', array( 'class' => 'differential-changeset-buttons', ), $this->buttons); } $id = $this->getID(); if ($this->symbolIndex) { Javelin::initBehavior( 'repository-crossreference', array( 'container' => $id, ) + $this->symbolIndex); } $display_filename = $changeset->getDisplayFilename(); $display_icon = $this->getFileIcon($display_filename); $icon = id(new PHUIIconView()) ->setIconFont($display_icon); return javelin_tag( 'div', array( 'sigil' => 'differential-changeset', 'meta' => array( 'left' => nonempty( $this->getVsChangesetID(), $this->changeset->getID()), 'right' => $this->changeset->getID(), 'renderURI' => $this->getRenderURI(), 'whitespace' => $this->getWhitespace(), 'highlight' => null, 'renderer' => $this->getRenderer(), 'ref' => $this->getRenderingRef(), 'autoload' => $this->getAutoload(), + 'loaded' => $this->getLoaded(), ), 'class' => $class, 'id' => $id, ), array( id(new PhabricatorAnchorView()) ->setAnchorName($changeset->getAnchorName()) ->setNavigationMarker(true) ->render(), $buttons, phutil_tag('h1', array( 'class' => 'differential-file-icon-header', ), array( $icon, $display_filename, )), javelin_tag( 'div', array( 'class' => 'changeset-view-content', 'sigil' => 'changeset-view-content', ), $this->renderChildren()), )); } } diff --git a/src/applications/differential/view/DifferentialChangesetListView.php b/src/applications/differential/view/DifferentialChangesetListView.php index 077cf688ca..d965156a85 100644 --- a/src/applications/differential/view/DifferentialChangesetListView.php +++ b/src/applications/differential/view/DifferentialChangesetListView.php @@ -1,361 +1,379 @@ parser = $parser; + return $this; + } + + public function getParser() { + return $this->parser; + } public function setTitle($title) { $this->title = $title; return $this; } private function getTitle() { return $this->title; } public function setBranch($branch) { $this->branch = $branch; return $this; } private function getBranch() { return $this->branch; } public function setChangesets($changesets) { $this->changesets = $changesets; return $this; } public function setVisibleChangesets($visible_changesets) { $this->visibleChangesets = $visible_changesets; return $this; } public function setInlineCommentControllerURI($uri) { $this->inlineURI = $uri; return $this; } public function setRepository(PhabricatorRepository $repository) { $this->repository = $repository; return $this; } public function setDiff(DifferentialDiff $diff) { $this->diff = $diff; return $this; } public function setRenderingReferences(array $references) { $this->references = $references; return $this; } public function setSymbolIndexes(array $indexes) { $this->symbolIndexes = $indexes; return $this; } public function setRenderURI($render_uri) { $this->renderURI = $render_uri; return $this; } public function setWhitespace($whitespace) { $this->whitespace = $whitespace; return $this; } public function setVsMap(array $vs_map) { $this->vsMap = $vs_map; return $this; } public function getVsMap() { return $this->vsMap; } public function setStandaloneURI($uri) { $this->standaloneURI = $uri; return $this; } public function setRawFileURIs($l, $r) { $this->leftRawFileURI = $l; $this->rightRawFileURI = $r; return $this; } public function render() { $this->requireResource('differential-changeset-view-css'); $changesets = $this->changesets; Javelin::initBehavior('differential-toggle-files', array( 'pht' => array( 'undo' => pht('Undo'), 'collapsed' => pht('This file content has been collapsed.'), ), )); Javelin::initBehavior( 'differential-dropdown-menus', array( 'pht' => array( 'Open in Editor' => pht('Open in Editor'), 'Show Entire File' => pht('Show Entire File'), 'Entire File Shown' => pht('Entire File Shown'), "Can't Toggle Unloaded File" => pht("Can't Toggle Unloaded File"), 'Expand File' => pht('Expand File'), 'Collapse File' => pht('Collapse File'), 'Browse in Diffusion' => pht('Browse in Diffusion'), 'View Standalone' => pht('View Standalone'), 'Show Raw File (Left)' => pht('Show Raw File (Left)'), 'Show Raw File (Right)' => pht('Show Raw File (Right)'), 'Configure Editor' => pht('Configure Editor'), 'Load Changes' => pht('Load Changes'), 'View Side-by-Side' => pht('View Side-by-Side'), 'View Unified' => pht('View Unified'), 'Change Text Encoding...' => pht('Change Text Encoding...'), 'Highlight As...' => pht('Highlight As...'), ), )); $renderer = DifferentialChangesetParser::getDefaultRendererForViewer( $this->getUser()); $output = array(); $ids = array(); foreach ($changesets as $key => $changeset) { $file = $changeset->getFilename(); $class = 'differential-changeset'; if (!$this->inlineURI) { $class .= ' differential-changeset-noneditable'; } $ref = $this->references[$key]; $detail = id(new DifferentialChangesetDetailView()) ->setUser($this->getUser()); $uniq_id = 'diff-'.$changeset->getAnchorName(); $detail->setID($uniq_id); $view_options = $this->renderViewOptionsDropdown( $detail, $ref, $changeset); $detail->setChangeset($changeset); $detail->addButton($view_options); $detail->setSymbolIndex(idx($this->symbolIndexes, $key)); $detail->setVsChangesetID(idx($this->vsMap, $changeset->getID())); $detail->setEditable(true); $detail->setRenderingRef($ref); - $detail->setAutoload(isset($this->visibleChangesets[$key])); $detail->setRenderURI($this->renderURI); $detail->setWhitespace($this->whitespace); $detail->setRenderer($renderer); - if (isset($this->visibleChangesets[$key])) { - $load = 'Loading...'; + if ($this->getParser()) { + $detail->appendChild($this->getParser()->renderChangeset()); + $detail->setLoaded(true); } else { - $load = javelin_tag( - 'a', - array( - 'class' => 'button grey', - 'href' => '#'.$uniq_id, - 'sigil' => 'differential-load', - 'meta' => array( - 'id' => $detail->getID(), - 'kill' => true, + $detail->setAutoload(isset($this->visibleChangesets[$key])); + if (isset($this->visibleChangesets[$key])) { + $load = 'Loading...'; + } else { + $load = javelin_tag( + 'a', + array( + 'class' => 'button grey', + 'href' => '#'.$uniq_id, + 'sigil' => 'differential-load', + 'meta' => array( + 'id' => $detail->getID(), + 'kill' => true, + ), + 'mustcapture' => true, + ), + pht('Load File')); + } + $detail->appendChild( + phutil_tag( + 'div', + array( + 'id' => $uniq_id, ), - 'mustcapture' => true, - ), - pht('Load File')); + phutil_tag( + 'div', + array('class' => 'differential-loading'), + $load))); } - $detail->appendChild( - phutil_tag( - 'div', - array( - 'id' => $uniq_id, - ), - phutil_tag('div', array('class' => 'differential-loading'), $load))); - $output[] = $detail->render(); + $output[] = $detail->render(); $ids[] = $detail->getID(); } $this->requireResource('aphront-tooltip-css'); $this->initBehavior('differential-populate', array( 'changesetViewIDs' => $ids, )); $this->initBehavior('differential-comment-jump', array()); if ($this->inlineURI) { $undo_templates = $this->renderUndoTemplates(); Javelin::initBehavior('differential-edit-inline-comments', array( 'uri' => $this->inlineURI, 'undo_templates' => $undo_templates, 'stage' => 'differential-review-stage', )); } $header = id(new PHUIHeaderView()) ->setHeader($this->getTitle()); $content = phutil_tag( 'div', array( 'class' => 'differential-review-stage', 'id' => 'differential-review-stage', ), $output); $object_box = id(new PHUIObjectBoxView()) ->setHeader($header) ->appendChild($content); return $object_box; } /** * Render the "Undo" markup for the inline comment undo feature. */ private function renderUndoTemplates() { $link = javelin_tag( 'a', array( 'href' => '#', 'sigil' => 'differential-inline-comment-undo', ), pht('Undo')); $div = phutil_tag( 'div', array( 'class' => 'differential-inline-undo', ), array('Changes discarded. ', $link)); return array( 'l' => phutil_tag('table', array(), phutil_tag('tr', array(), array( phutil_tag('th', array()), phutil_tag('td', array(), $div), phutil_tag('th', array()), phutil_tag('td', array('colspan' => 3)), ))), 'r' => phutil_tag('table', array(), phutil_tag('tr', array(), array( phutil_tag('th', array()), phutil_tag('td', array()), phutil_tag('th', array()), phutil_tag('td', array('colspan' => 3), $div), ))), ); } private function renderViewOptionsDropdown( DifferentialChangesetDetailView $detail, $ref, DifferentialChangeset $changeset) { $meta = array(); $qparams = array( 'ref' => $ref, 'whitespace' => $this->whitespace, ); if ($this->standaloneURI) { $uri = new PhutilURI($this->standaloneURI); $uri->setQueryParams($uri->getQueryParams() + $qparams); $meta['standaloneURI'] = (string)$uri; } $repository = $this->repository; if ($repository) { try { $meta['diffusionURI'] = (string)$repository->getDiffusionBrowseURIForPath( $this->user, $changeset->getAbsoluteRepositoryPath($repository, $this->diff), idx($changeset->getMetadata(), 'line:first'), $this->getBranch()); } catch (DiffusionSetupException $e) { // Ignore } } $change = $changeset->getChangeType(); if ($this->leftRawFileURI) { if ($change != DifferentialChangeType::TYPE_ADD) { $uri = new PhutilURI($this->leftRawFileURI); $uri->setQueryParams($uri->getQueryParams() + $qparams); $meta['leftURI'] = (string)$uri; } } if ($this->rightRawFileURI) { if ($change != DifferentialChangeType::TYPE_DELETE && $change != DifferentialChangeType::TYPE_MULTICOPY) { $uri = new PhutilURI($this->rightRawFileURI); $uri->setQueryParams($uri->getQueryParams() + $qparams); $meta['rightURI'] = (string)$uri; } } $user = $this->user; if ($user && $repository) { $path = ltrim( $changeset->getAbsoluteRepositoryPath($repository, $this->diff), '/'); $line = idx($changeset->getMetadata(), 'line:first', 1); $callsign = $repository->getCallsign(); $editor_link = $user->loadEditorLink($path, $line, $callsign); if ($editor_link) { $meta['editor'] = $editor_link; } else { $meta['editorConfigure'] = '/settings/panel/display/'; } } $meta['containerID'] = $detail->getID(); $caret = phutil_tag('span', array('class' => 'caret'), ''); return javelin_tag( 'a', array( 'class' => 'button grey small dropdown', 'meta' => $meta, 'href' => idx($meta, 'detailURI', '#'), 'target' => '_blank', 'sigil' => 'differential-view-options', ), array(pht('View Options'), $caret)); } } diff --git a/webroot/rsrc/js/application/differential/ChangesetViewManager.js b/webroot/rsrc/js/application/differential/ChangesetViewManager.js index 36f097cf49..f80883f910 100644 --- a/webroot/rsrc/js/application/differential/ChangesetViewManager.js +++ b/webroot/rsrc/js/application/differential/ChangesetViewManager.js @@ -1,365 +1,366 @@ /** * @provides changeset-view-manager * @requires javelin-dom * javelin-util * javelin-stratcom * javelin-install * javelin-workflow * javelin-router * javelin-behavior-device * javelin-vector */ JX.install('ChangesetViewManager', { construct : function(node) { this._node = node; var data = this._getNodeData(); this._renderURI = data.renderURI; this._ref = data.ref; this._whitespace = data.whitespace; this._renderer = data.renderer; this._highlight = data.highlight; this._encoding = data.encoding; + this._loaded = data.loaded; }, members: { _node: null, _loaded: false, _sequence: 0, _stabilize: false, _renderURI: null, _ref: null, _whitespace: null, _renderer: null, _highlight: null, _encoding: null, /** * Has the content of this changeset been loaded? * * This method returns `true` if a request has been fired, even if the * response has not returned yet. * * @return bool True if the content has been loaded. */ isLoaded: function() { return this._loaded; }, /** * Configure stabilization of the document position on content load. * * When we dump the changeset into the document, we can try to stabilize * the document scroll position so that the user doesn't feel like they * are jumping around as things load in. This is generally useful when * populating initial changes. * * However, if a user explicitly requests a content load by clicking a * "Load" link or using the dropdown menu, this stabilization generally * feels unnatural, so we don't use it in response to explicit user action. * * @param bool True to stabilize the next content fill. * @return this */ setStabilize: function(stabilize) { this._stabilize = stabilize; return this; }, /** * Should this changeset load immediately when the page loads? * * Normally, changes load immediately, but if a diff or commit is very * large we stop doing this and have the user load files explicitly, or * choose to load everything. * * @return bool True if the changeset should load automatically when the * page loads. */ shouldAutoload: function() { return this._getNodeData().autoload; }, /** * Load this changeset, if it isn't already loading. * * This fires a request to fill the content of this changeset, provided * there isn't already a request in flight. To force a reload, use * @{method:reload}. * * @return this */ load: function() { if (this._loaded) { return this; } return this.reload(); }, /** * Reload the changeset content. * * This method always issues a request, even if the content is already * loading. To load conditionally, use @{method:load}. * * @return this */ reload: function() { this._loaded = true; this._sequence++; var params = this._getViewParameters(); var workflow = new JX.Workflow(this._renderURI, params) .setHandler(JX.bind(this, this._onresponse, this._sequence)); this._startContentWorkflow(workflow); JX.DOM.setContent( this._getContentFrame(), JX.$N( 'div', {className: 'differential-loading'}, 'Loading...')); return this; }, /** * Load missing context in a changeset. * * We do this when the user clicks "Show X Lines". We also expand all of * the missing context when they "Show Entire File". * * @param string Line range specification, like "0-40/0-20". * @param node Row where the context should be rendered after loading. * @param bool True if this is a bulk load of multiple context blocks. * @return this */ loadContext: function(range, target, bulk) { var params = this._getViewParameters(); params.range = range; var container = JX.DOM.scry(target, 'td')[0]; // TODO: pht() JX.DOM.setContent(container, 'Loading...'); JX.DOM.alterClass(target, 'differential-show-more-loading', true); var workflow = new JX.Workflow(this._renderURI, params) .setHandler(JX.bind(this, this._oncontext, target)); if (bulk) { // If we're loading a bunch of these because the viewer clicked // "Show Entire File Content" or similar, use lower-priority requests // and draw a progress bar. this._startContentWorkflow(workflow); } else { // If this is a single click on a context link, use a higher priority // load without a chrome change. workflow.start(); } return this; }, _startContentWorkflow: function(workflow) { var routable = workflow.getRoutable(); routable .setPriority(500) .setType('content') .setKey(this._getRoutableKey()); JX.Router.getInstance().queue(routable); }, /** * Receive a response to a context request. */ _oncontext: function(target, response) { var table = JX.$H(response.changeset).getNode(); var root = target.parentNode; this._moveRows(table, root, target); root.removeChild(target); }, _moveRows: function(src, dst, before) { var rows = JX.DOM.scry(src, 'tr'); for (var ii = 0; ii < rows.length; ii++) { // Find the table this belongs to. If it's a sub-table, like a // table in an inline comment, don't copy it. if (JX.DOM.findAbove(rows[ii], 'table') !== src) { continue; } if (before) { dst.insertBefore(rows[ii], before); } else { dst.appendChild(rows[ii]); } } }, /** * Get parameters which define the current rendering options. */ _getViewParameters: function() { return { ref: this._ref, whitespace: this._whitespace || '', renderer: this.getRenderer() || '', highlight: this._highlight || '', encoding: this._encoding || '' }; }, /** * Get the active @{class:JX.Routable} for this changeset. * * After issuing a request with @{method:load} or @{method:reload}, you * can adjust routable settings (like priority) by querying the routable * with this method. Note that there may not be a current routable. * * @return JX.Routable|null Active routable, if one exists. */ getRoutable: function() { return JX.Router.getInstance().getRoutableByKey(this._getRoutableKey()); }, setRenderer: function(renderer) { this._renderer = renderer; return this; }, getRenderer: function() { if (this._renderer !== null) { return this._renderer; } // NOTE: If you load the page at one device resolution and then resize to // a different one we don't re-render the diffs, because it's a // complicated mess and you could lose inline comments, cursor positions, // etc. return (JX.Device.getDevice() == 'desktop') ? '2up' : '1up'; }, setEncoding: function(encoding) { this._encoding = encoding; return this; }, getEncoding: function() { return this._encoding; }, setHighlight: function(highlight) { this._highlight = highlight; return this; }, getHighlight: function() { return this._highlight; }, _getNodeData: function() { return JX.Stratcom.getData(this._node); }, _onresponse: function(sequence, response) { if (sequence != this._sequence) { // If this isn't the most recent request, ignore it. This normally // means the user changed view settings between the time the page loaded // and the content filled. return; } // As we populate the changeset list, we try to hold the document scroll // position steady, so that, e.g., users who want to leave a comment on a // diff with a large number of changes don't constantly have the text // area scrolled off the bottom of the screen until the entire diff loads. // // There are two three major cases here: // // - If we're near the top of the document, never scroll. // - If we're near the bottom of the document, always scroll. // - Otherwise, scroll if the changes were above the midline of the // viewport. var target = this._node; var old_pos = JX.Vector.getScroll(); var old_view = JX.Vector.getViewport(); var old_dim = JX.Vector.getDocument(); // Number of pixels away from the top or bottom of the document which // count as "nearby". var sticky = 480; var near_top = (old_pos.y <= sticky); var near_bot = ((old_pos.y + old_view.y) >= (old_dim.y - sticky)); var target_pos = JX.Vector.getPos(target); var target_dim = JX.Vector.getDim(target); var target_mid = (target_pos.y + (target_dim.y / 2)); var view_mid = (old_pos.y + (old_view.y / 2)); var above_mid = (target_mid < view_mid); var frame = this._getContentFrame(); JX.DOM.setContent(frame, JX.$H(response.changeset)); if (this._stabilize) { if (!near_top) { if (near_bot || above_mid) { // Figure out how much taller the document got. var delta = (JX.Vector.getDocument().y - old_dim.y); JX.DOM.scrollToPosition(old_pos.x, old_pos.y + delta); } } this._stabilize = false; } if (response.coverage) { for (var k in response.coverage) { try { JX.DOM.replace(JX.$(k), JX.$H(response.coverage[k])); } catch (ignored) { // Not terribly important. } } } }, _getContentFrame: function() { return JX.DOM.find(this._node, 'div', 'changeset-view-content'); }, _getRoutableKey: function() { return 'changeset-view.' + this._ref + '.' + this._sequence; } }, statics: { getForNode: function(node) { var data = JX.Stratcom.getData(node); if (!data.changesetViewManager) { data.changesetViewManager = new JX.ChangesetViewManager(node); } return data.changesetViewManager; } } });