diff --git a/src/applications/console/core/DarkConsoleCore.php b/src/applications/console/core/DarkConsoleCore.php index ddb22b9cfe..437d6f0be7 100644 --- a/src/applications/console/core/DarkConsoleCore.php +++ b/src/applications/console/core/DarkConsoleCore.php @@ -1,136 +1,138 @@ <?php final class DarkConsoleCore extends Phobject { private $plugins = array(); const STORAGE_VERSION = 1; public function __construct() { $this->plugins = id(new PhutilClassMapQuery()) ->setAncestorClass('DarkConsolePlugin') ->execute(); foreach ($this->plugins as $plugin) { $plugin->setConsoleCore($this); $plugin->didStartup(); } } public function getPlugins() { return $this->plugins; } public function getKey(AphrontRequest $request) { $plugins = $this->getPlugins(); foreach ($plugins as $plugin) { $plugin->setRequest($request); $plugin->willShutdown(); } foreach ($plugins as $plugin) { $plugin->didShutdown(); } foreach ($plugins as $plugin) { $plugin->setData($plugin->generateData()); } $plugins = msort($plugins, 'getOrderKey'); $key = Filesystem::readRandomCharacters(24); $tabs = array(); $data = array(); foreach ($plugins as $plugin) { $class = get_class($plugin); $tabs[] = array( 'class' => $class, 'name' => $plugin->getName(), 'color' => $plugin->getColor(), ); $data[$class] = $this->sanitizeForJSON($plugin->getData()); } $storage = array( 'vers' => self::STORAGE_VERSION, 'tabs' => $tabs, 'data' => $data, 'user' => $request->getUser() ? $request->getUser()->getPHID() : null, ); $cache = new PhabricatorKeyValueDatabaseCache(); $cache = new PhutilKeyValueCacheProfiler($cache); $cache->setProfiler(PhutilServiceProfiler::getInstance()); // This encoding may fail if there are, e.g., database queries which // include binary data. It would be a little cleaner to try to strip these, // but just do something non-broken here if we end up with unrepresentable // data. $json = @json_encode($storage); if (!$json) { $json = '{}'; } $cache->setKeys( array( 'darkconsole:'.$key => $json, ), $ttl = (60 * 60 * 6)); return $key; } public function getColor() { foreach ($this->getPlugins() as $plugin) { if ($plugin->getColor()) { return $plugin->getColor(); } } } public function render(AphrontRequest $request) { $user = $request->getUser(); $visible = $user->getUserSetting( PhabricatorDarkConsoleVisibleSetting::SETTINGKEY); return javelin_tag( 'div', array( 'id' => 'darkconsole', 'class' => 'dark-console', 'style' => $visible ? '' : 'display: none;', 'data-console-key' => $this->getKey($request), 'data-console-color' => $this->getColor(), ), ''); } /** * Sometimes, tab data includes binary information (like INSERT queries which * write file data into the database). To successfully JSON encode it, we * need to convert it to UTF-8. */ private function sanitizeForJSON($data) { if (is_object($data)) { return '<object:'.get_class($data).'>'; } else if (is_array($data)) { foreach ($data as $key => $value) { $data[$key] = $this->sanitizeForJSON($value); } return $data; + } else if (is_resource($data)) { + return '<resource>'; } else { // Truncate huge strings. Since the data doesn't really matter much, // just truncate bytes to avoid PhutilUTF8StringTruncator overhead. $length = strlen($data); $max = 4096; if ($length > $max) { $data = substr($data, 0, $max).'...<'.$length.' bytes>...'; } return phutil_utf8ize($data); } } } diff --git a/src/applications/files/document/PhabricatorDocumentEngine.php b/src/applications/files/document/PhabricatorDocumentEngine.php index 9593e1d98d..3a13c5eb4a 100644 --- a/src/applications/files/document/PhabricatorDocumentEngine.php +++ b/src/applications/files/document/PhabricatorDocumentEngine.php @@ -1,284 +1,288 @@ <?php abstract class PhabricatorDocumentEngine extends Phobject { private $viewer; private $highlightedLines = array(); private $encodingConfiguration; private $highlightingConfiguration; private $blameConfiguration = true; final public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } final public function getViewer() { return $this->viewer; } final public function setHighlightedLines(array $highlighted_lines) { $this->highlightedLines = $highlighted_lines; return $this; } final public function getHighlightedLines() { return $this->highlightedLines; } final public function canRenderDocument(PhabricatorDocumentRef $ref) { return $this->canRenderDocumentType($ref); } public function canDiffDocuments( PhabricatorDocumentRef $uref = null, PhabricatorDocumentRef $vref = null) { return false; } public function newBlockDiffViews( PhabricatorDocumentRef $uref, PhabricatorDocumentEngineBlock $ublock, PhabricatorDocumentRef $vref, PhabricatorDocumentEngineBlock $vblock) { $u_content = $this->newBlockContentView($uref, $ublock); $v_content = $this->newBlockContentView($vref, $vblock); return id(new PhabricatorDocumentEngineBlockDiff()) ->setOldContent($u_content) ->addOldClass('old') ->addOldClass('old-full') ->setNewContent($v_content) ->addNewClass('new') ->addNewClass('new-full'); } public function newBlockContentView( PhabricatorDocumentRef $ref, PhabricatorDocumentEngineBlock $block) { return $block->getContent(); } public function newEngineBlocks( PhabricatorDocumentRef $uref, PhabricatorDocumentRef $vref) { throw new PhutilMethodNotImplementedException(); } public function canConfigureEncoding(PhabricatorDocumentRef $ref) { return false; } public function canConfigureHighlighting(PhabricatorDocumentRef $ref) { return false; } public function canBlame(PhabricatorDocumentRef $ref) { return false; } final public function setEncodingConfiguration($config) { $this->encodingConfiguration = $config; return $this; } final public function getEncodingConfiguration() { return $this->encodingConfiguration; } final public function setHighlightingConfiguration($config) { $this->highlightingConfiguration = $config; return $this; } final public function getHighlightingConfiguration() { return $this->highlightingConfiguration; } final public function setBlameConfiguration($blame_configuration) { $this->blameConfiguration = $blame_configuration; return $this; } final public function getBlameConfiguration() { return $this->blameConfiguration; } final protected function getBlameEnabled() { return $this->blameConfiguration; } public function shouldRenderAsync(PhabricatorDocumentRef $ref) { return false; } abstract protected function canRenderDocumentType( PhabricatorDocumentRef $ref); final public function newDocument(PhabricatorDocumentRef $ref) { $can_complete = $this->canRenderCompleteDocument($ref); $can_partial = $this->canRenderPartialDocument($ref); if (!$can_complete && !$can_partial) { return $this->newMessage( pht( 'This document is too large to be rendered inline. (The document '. 'is %s bytes, the limit for this engine is %s bytes.)', new PhutilNumber($ref->getByteLength()), new PhutilNumber($this->getByteLengthLimit()))); } return $this->newDocumentContent($ref); } final public function newDocumentIcon(PhabricatorDocumentRef $ref) { return id(new PHUIIconView()) ->setIcon($this->getDocumentIconIcon($ref)); } abstract protected function newDocumentContent( PhabricatorDocumentRef $ref); protected function getDocumentIconIcon(PhabricatorDocumentRef $ref) { return 'fa-file-o'; } protected function getDocumentRenderingText(PhabricatorDocumentRef $ref) { return pht('Loading...'); } final public function getDocumentEngineKey() { return $this->getPhobjectClassConstant('ENGINEKEY'); } final public static function getAllEngines() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getDocumentEngineKey') ->execute(); } final public function newSortVector(PhabricatorDocumentRef $ref) { $content_score = $this->getContentScore($ref); // Prefer engines which can render the entire file over engines which // can only render a header, and engines which can render a header over // engines which can't render anything. if ($this->canRenderCompleteDocument($ref)) { $limit_score = 0; } else if ($this->canRenderPartialDocument($ref)) { $limit_score = 1; } else { $limit_score = 2; } return id(new PhutilSortVector()) ->addInt($limit_score) ->addInt(-$content_score); } protected function getContentScore(PhabricatorDocumentRef $ref) { return 2000; } abstract public function getViewAsLabel(PhabricatorDocumentRef $ref); public function getViewAsIconIcon(PhabricatorDocumentRef $ref) { $can_complete = $this->canRenderCompleteDocument($ref); $can_partial = $this->canRenderPartialDocument($ref); if (!$can_complete && !$can_partial) { return 'fa-times'; } return $this->getDocumentIconIcon($ref); } public function getViewAsIconColor(PhabricatorDocumentRef $ref) { $can_complete = $this->canRenderCompleteDocument($ref); if (!$can_complete) { return 'grey'; } return null; } final public static function getEnginesForRef( PhabricatorUser $viewer, PhabricatorDocumentRef $ref) { $engines = self::getAllEngines(); foreach ($engines as $key => $engine) { $engine = id(clone $engine) ->setViewer($viewer); if (!$engine->canRenderDocument($ref)) { unset($engines[$key]); continue; } $engines[$key] = $engine; } if (!$engines) { throw new Exception(pht('No content engine can render this document.')); } $vectors = array(); foreach ($engines as $key => $usable_engine) { $vectors[$key] = $usable_engine->newSortVector($ref); } $vectors = msortv($vectors, 'getSelf'); return array_select_keys($engines, array_keys($vectors)); } protected function getByteLengthLimit() { return (1024 * 1024 * 8); } protected function canRenderCompleteDocument(PhabricatorDocumentRef $ref) { $limit = $this->getByteLengthLimit(); if ($limit) { $length = $ref->getByteLength(); if ($length > $limit) { return false; } } return true; } protected function canRenderPartialDocument(PhabricatorDocumentRef $ref) { return false; } protected function newMessage($message) { return phutil_tag( 'div', array( 'class' => 'document-engine-error', ), $message); } final public function newLoadingContent(PhabricatorDocumentRef $ref) { $spinner = id(new PHUIIconView()) ->setIcon('fa-gear') ->addClass('ph-spin'); return phutil_tag( 'div', array( 'class' => 'document-engine-loading', ), array( $spinner, $this->getDocumentRenderingText($ref), )); } + public function shouldSuggestEngine(PhabricatorDocumentRef $ref) { + return false; + } + } diff --git a/src/applications/files/document/PhabricatorJupyterDocumentEngine.php b/src/applications/files/document/PhabricatorJupyterDocumentEngine.php index 834a880b42..3e479fdace 100644 --- a/src/applications/files/document/PhabricatorJupyterDocumentEngine.php +++ b/src/applications/files/document/PhabricatorJupyterDocumentEngine.php @@ -1,751 +1,755 @@ <?php final class PhabricatorJupyterDocumentEngine extends PhabricatorDocumentEngine { const ENGINEKEY = 'jupyter'; public function getViewAsLabel(PhabricatorDocumentRef $ref) { return pht('View as Jupyter Notebook'); } protected function getDocumentIconIcon(PhabricatorDocumentRef $ref) { return 'fa-sun-o'; } protected function getDocumentRenderingText(PhabricatorDocumentRef $ref) { return pht('Rendering Jupyter Notebook...'); } public function shouldRenderAsync(PhabricatorDocumentRef $ref) { return true; } protected function getContentScore(PhabricatorDocumentRef $ref) { $name = $ref->getName(); if (preg_match('/\\.ipynb\z/i', $name)) { return 2000; } return 500; } protected function canRenderDocumentType(PhabricatorDocumentRef $ref) { return $ref->isProbablyJSON(); } public function canDiffDocuments( PhabricatorDocumentRef $uref = null, PhabricatorDocumentRef $vref = null) { return true; } public function newEngineBlocks( PhabricatorDocumentRef $uref = null, PhabricatorDocumentRef $vref = null) { $blocks = new PhabricatorDocumentEngineBlocks(); try { if ($uref) { $u_blocks = $this->newDiffBlocks($uref); } else { $u_blocks = array(); } if ($vref) { $v_blocks = $this->newDiffBlocks($vref); } else { $v_blocks = array(); } $blocks->addBlockList($uref, $u_blocks); $blocks->addBlockList($vref, $v_blocks); } catch (Exception $ex) { $blocks->addMessage($ex->getMessage()); } return $blocks; } public function newBlockDiffViews( PhabricatorDocumentRef $uref, PhabricatorDocumentEngineBlock $ublock, PhabricatorDocumentRef $vref, PhabricatorDocumentEngineBlock $vblock) { $ucell = $ublock->getContent(); $vcell = $vblock->getContent(); $utype = idx($ucell, 'cell_type'); $vtype = idx($vcell, 'cell_type'); if ($utype === $vtype) { switch ($utype) { case 'markdown': $usource = idx($ucell, 'source'); $usource = implode('', $usource); $vsource = idx($vcell, 'source'); $vsource = implode('', $vsource); $diff = id(new PhutilProseDifferenceEngine()) ->getDiff($usource, $vsource); $u_content = $this->newProseDiffCell($diff, array('=', '-')); $v_content = $this->newProseDiffCell($diff, array('=', '+')); $u_content = $this->newJupyterCell(null, $u_content, null); $v_content = $this->newJupyterCell(null, $v_content, null); $u_content = $this->newCellContainer($u_content); $v_content = $this->newCellContainer($v_content); return id(new PhabricatorDocumentEngineBlockDiff()) ->setOldContent($u_content) ->addOldClass('old') ->setNewContent($v_content) ->addNewClass('new'); case 'code/line': $usource = idx($ucell, 'raw'); $vsource = idx($vcell, 'raw'); $udisplay = idx($ucell, 'display'); $vdisplay = idx($vcell, 'display'); $ulabel = idx($ucell, 'label'); $vlabel = idx($vcell, 'label'); $intraline_segments = ArcanistDiffUtils::generateIntralineDiff( $usource, $vsource); $u_segments = array(); foreach ($intraline_segments[0] as $u_segment) { $u_segments[] = $u_segment; } $v_segments = array(); foreach ($intraline_segments[1] as $v_segment) { $v_segments[] = $v_segment; } $usource = PhabricatorDifferenceEngine::applyIntralineDiff( $udisplay, $u_segments); $vsource = PhabricatorDifferenceEngine::applyIntralineDiff( $vdisplay, $v_segments); $u_content = $this->newCodeLineCell($ucell, $usource); $v_content = $this->newCodeLineCell($vcell, $vsource); $classes = array( 'jupyter-cell-flush', ); $u_content = $this->newJupyterCell($ulabel, $u_content, $classes); $v_content = $this->newJupyterCell($vlabel, $v_content, $classes); $u_content = $this->newCellContainer($u_content); $v_content = $this->newCellContainer($v_content); return id(new PhabricatorDocumentEngineBlockDiff()) ->setOldContent($u_content) ->addOldClass('old') ->setNewContent($v_content) ->addNewClass('new'); } } return parent::newBlockDiffViews($uref, $ublock, $vref, $vblock); } public function newBlockContentView( PhabricatorDocumentRef $ref, PhabricatorDocumentEngineBlock $block) { $viewer = $this->getViewer(); $cell = $block->getContent(); $cell_content = $this->renderJupyterCell($viewer, $cell); return $this->newCellContainer($cell_content); } private function newCellContainer($cell_content) { $notebook_table = phutil_tag( 'table', array( 'class' => 'jupyter-notebook', ), $cell_content); $container = phutil_tag( 'div', array( 'class' => 'document-engine-jupyter document-engine-diff', ), $notebook_table); return $container; } private function newProseDiffCell(PhutilProseDiff $diff, array $mask) { $mask = array_fuse($mask); $result = array(); foreach ($diff->getParts() as $part) { $type = $part['type']; $text = $part['text']; if (!isset($mask[$type])) { continue; } switch ($type) { case '-': $result[] = phutil_tag( 'span', array( 'class' => 'bright', ), $text); break; case '+': $result[] = phutil_tag( 'span', array( 'class' => 'bright', ), $text); break; case '=': $result[] = $text; break; } } return array( null, phutil_tag( 'div', array( 'class' => 'jupyter-cell-markdown', ), $result), ); } private function newDiffBlocks(PhabricatorDocumentRef $ref) { $viewer = $this->getViewer(); $content = $ref->loadData(); $cells = $this->newCells($content, true); $idx = 1; $blocks = array(); foreach ($cells as $cell) { // When the cell is a source code line, we can hash just the raw // input rather than all the cell metadata. switch (idx($cell, 'cell_type')) { case 'code/line': $hash_input = $cell['raw']; break; case 'markdown': $hash_input = implode('', $cell['source']); break; default: $hash_input = serialize($cell); break; } $hash = PhabricatorHash::digestWithNamedKey( $hash_input, 'document-engine.content-digest'); $blocks[] = id(new PhabricatorDocumentEngineBlock()) ->setBlockKey($idx) ->setDifferenceHash($hash) ->setContent($cell); $idx++; } return $blocks; } protected function newDocumentContent(PhabricatorDocumentRef $ref) { $viewer = $this->getViewer(); $content = $ref->loadData(); try { $cells = $this->newCells($content, false); } catch (Exception $ex) { return $this->newMessage($ex->getMessage()); } $rows = array(); foreach ($cells as $cell) { $rows[] = $this->renderJupyterCell($viewer, $cell); } $notebook_table = phutil_tag( 'table', array( 'class' => 'jupyter-notebook', ), $rows); $container = phutil_tag( 'div', array( 'class' => 'document-engine-jupyter', ), $notebook_table); return $container; } private function newCells($content, $for_diff) { try { $data = phutil_json_decode($content); } catch (PhutilJSONParserException $ex) { throw new Exception( pht( 'This is not a valid JSON document and can not be rendered as '. 'a Jupyter notebook: %s.', $ex->getMessage())); } if (!is_array($data)) { throw new Exception( pht( 'This document does not encode a valid JSON object and can not '. 'be rendered as a Jupyter notebook.')); } $nbformat = idx($data, 'nbformat'); if (!strlen($nbformat)) { throw new Exception( pht( 'This document is missing an "nbformat" field. Jupyter notebooks '. 'must have this field.')); } if ($nbformat !== 4) { throw new Exception( pht( 'This Jupyter notebook uses an unsupported version of the file '. 'format (found version %s, expected version 4).', $nbformat)); } $cells = idx($data, 'cells'); if (!is_array($cells)) { throw new Exception( pht( 'This Jupyter notebook does not specify a list of "cells".')); } if (!$cells) { throw new Exception( pht( 'This Jupyter notebook does not specify any notebook cells.')); } if (!$for_diff) { return $cells; } // If we're extracting cells to build a diff view, split code cells into // individual lines and individual outputs. We want users to be able to // add inline comments to each line and each output block. $results = array(); foreach ($cells as $cell) { $cell_type = idx($cell, 'cell_type'); if ($cell_type === 'markdown') { $source = $cell['source']; $source = implode('', $source); // Attempt to split contiguous blocks of markdown into smaller // pieces. $chunks = preg_split( '/\n\n+/', $source); foreach ($chunks as $chunk) { $result = $cell; $result['source'] = array($chunk); $results[] = $result; } continue; } if ($cell_type !== 'code') { $results[] = $cell; continue; } $label = $this->newCellLabel($cell); $lines = idx($cell, 'source'); if (!is_array($lines)) { $lines = array(); } $content = $this->highlightLines($lines); $count = count($lines); for ($ii = 0; $ii < $count; $ii++) { $is_head = ($ii === 0); $is_last = ($ii === ($count - 1)); if ($is_head) { $line_label = $label; } else { $line_label = null; } $results[] = array( 'cell_type' => 'code/line', 'label' => $line_label, 'raw' => $lines[$ii], 'display' => idx($content, $ii), 'head' => $is_head, 'last' => $is_last, ); } $outputs = array(); $output_list = idx($cell, 'outputs'); if (is_array($output_list)) { foreach ($output_list as $output) { $results[] = array( 'cell_type' => 'code/output', 'output' => $output, ); } } } return $results; } private function renderJupyterCell( PhabricatorUser $viewer, array $cell) { list($label, $content) = $this->renderJupyterCellContent($viewer, $cell); $classes = null; switch (idx($cell, 'cell_type')) { case 'code/line': $classes = 'jupyter-cell-flush'; break; } return $this->newJupyterCell( $label, $content, $classes); } private function newJupyterCell($label, $content, $classes) { $label_cell = phutil_tag( 'td', array( 'class' => 'jupyter-label', ), $label); $content_cell = phutil_tag( 'td', array( 'class' => $classes, ), $content); return phutil_tag( 'tr', array(), array( $label_cell, $content_cell, )); } private function renderJupyterCellContent( PhabricatorUser $viewer, array $cell) { $cell_type = idx($cell, 'cell_type'); switch ($cell_type) { case 'markdown': return $this->newMarkdownCell($cell); case 'code': return $this->newCodeCell($cell); case 'code/line': return $this->newCodeLineCell($cell); case 'code/output': return $this->newCodeOutputCell($cell); } $json_content = id(new PhutilJSON()) ->encodeFormatted($cell); return $this->newRawCell($json_content); } private function newRawCell($content) { return array( null, phutil_tag( 'div', array( 'class' => 'jupyter-cell-raw PhabricatorMonospaced', ), $content), ); } private function newMarkdownCell(array $cell) { $content = idx($cell, 'source'); if (!is_array($content)) { $content = array(); } // TODO: This should ideally highlight as Markdown, but the "md" // highlighter in Pygments is painfully slow and not terribly useful. $content = $this->highlightLines($content, 'txt'); return array( null, phutil_tag( 'div', array( 'class' => 'jupyter-cell-markdown', ), $content), ); } private function newCodeCell(array $cell) { $label = $this->newCellLabel($cell); $content = idx($cell, 'source'); if (!is_array($content)) { $content = array(); } $content = $this->highlightLines($content); $outputs = array(); $output_list = idx($cell, 'outputs'); if (is_array($output_list)) { foreach ($output_list as $output) { $outputs[] = $this->newOutput($output); } } return array( $label, array( phutil_tag( 'div', array( 'class' => 'jupyter-cell-code jupyter-cell-code-block '. 'PhabricatorMonospaced remarkup-code', ), array( $content, )), $outputs, ), ); } private function newCodeLineCell(array $cell, $content = null) { $classes = array(); $classes[] = 'PhabricatorMonospaced'; $classes[] = 'remarkup-code'; $classes[] = 'jupyter-cell-code'; $classes[] = 'jupyter-cell-code-line'; if ($cell['head']) { $classes[] = 'jupyter-cell-code-head'; } if ($cell['last']) { $classes[] = 'jupyter-cell-code-last'; } $classes = implode(' ', $classes); if ($content === null) { $content = $cell['display']; } return array( $cell['label'], array( phutil_tag( 'div', array( 'class' => $classes, ), array( $content, )), ), ); } private function newCodeOutputCell(array $cell) { return array( null, $this->newOutput($cell['output']), ); } private function newOutput(array $output) { if (!is_array($output)) { return pht('<Invalid Output>'); } $classes = array( 'jupyter-output', 'PhabricatorMonospaced', ); $output_name = idx($output, 'name'); switch ($output_name) { case 'stderr': $classes[] = 'jupyter-output-stderr'; break; } $output_type = idx($output, 'output_type'); switch ($output_type) { case 'execute_result': case 'display_data': $data = idx($output, 'data'); $image_formats = array( 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', ); foreach ($image_formats as $image_format) { if (!isset($data[$image_format])) { continue; } $raw_data = $data[$image_format]; if (!is_array($raw_data)) { $raw_data = array($raw_data); } $raw_data = implode('', $raw_data); $content = phutil_tag( 'img', array( 'src' => 'data:'.$image_format.';base64,'.$raw_data, )); break 2; } if (isset($data['text/html'])) { $content = $data['text/html']; $classes[] = 'jupyter-output-html'; break; } if (isset($data['application/javascript'])) { $content = $data['application/javascript']; $classes[] = 'jupyter-output-html'; break; } if (isset($data['text/plain'])) { $content = $data['text/plain']; break; } break; case 'stream': default: $content = idx($output, 'text'); if (!is_array($content)) { $content = array(); } $content = implode('', $content); break; } return phutil_tag( 'div', array( 'class' => implode(' ', $classes), ), $content); } private function newCellLabel(array $cell) { $execution_count = idx($cell, 'execution_count'); if ($execution_count) { $label = 'In ['.$execution_count.']:'; } else { $label = null; } return $label; } private function highlightLines(array $lines, $force_language = null) { if ($force_language === null) { $head = head($lines); $matches = null; if (preg_match('/^%%(.*)$/', $head, $matches)) { $restore = array_shift($lines); $lang = $matches[1]; } else { $restore = null; $lang = 'py'; } } else { $restore = null; $lang = $force_language; } $content = PhabricatorSyntaxHighlighter::highlightWithLanguage( $lang, implode('', $lines)); $content = phutil_split_lines($content); if ($restore !== null) { $language_tag = phutil_tag( 'span', array( 'class' => 'language-tag', ), $restore); array_unshift($content, $language_tag); } return $content; } + public function shouldSuggestEngine(PhabricatorDocumentRef $ref) { + return true; + } + } diff --git a/src/applications/files/document/render/PhabricatorDocumentRenderingEngine.php b/src/applications/files/document/render/PhabricatorDocumentRenderingEngine.php index 2524f77821..5a4cb77340 100644 --- a/src/applications/files/document/render/PhabricatorDocumentRenderingEngine.php +++ b/src/applications/files/document/render/PhabricatorDocumentRenderingEngine.php @@ -1,338 +1,344 @@ <?php abstract class PhabricatorDocumentRenderingEngine extends Phobject { private $request; private $controller; private $activeEngine; private $ref; final public function setRequest(AphrontRequest $request) { $this->request = $request; return $this; } final public function getRequest() { if (!$this->request) { throw new PhutilInvalidStateException('setRequest'); } return $this->request; } final public function setController(PhabricatorController $controller) { $this->controller = $controller; return $this; } final public function getController() { if (!$this->controller) { throw new PhutilInvalidStateException('setController'); } return $this->controller; } final protected function getActiveEngine() { return $this->activeEngine; } final protected function getRef() { return $this->ref; } final public function newDocumentView(PhabricatorDocumentRef $ref) { $request = $this->getRequest(); $viewer = $request->getViewer(); $engines = PhabricatorDocumentEngine::getEnginesForRef($viewer, $ref); $engine_key = $this->getSelectedDocumentEngineKey(); if (!isset($engines[$engine_key])) { $engine_key = head_key($engines); } $engine = $engines[$engine_key]; $lines = $this->getSelectedLineRange(); if ($lines) { $engine->setHighlightedLines(range($lines[0], $lines[1])); } $encode_setting = $request->getStr('encode'); if (strlen($encode_setting)) { $engine->setEncodingConfiguration($encode_setting); } $highlight_setting = $request->getStr('highlight'); if (strlen($highlight_setting)) { $engine->setHighlightingConfiguration($highlight_setting); } $blame_setting = ($request->getStr('blame') !== 'off'); $engine->setBlameConfiguration($blame_setting); $views = array(); foreach ($engines as $candidate_key => $candidate_engine) { $label = $candidate_engine->getViewAsLabel($ref); if ($label === null) { continue; } $view_uri = $this->newRefViewURI($ref, $candidate_engine); $view_icon = $candidate_engine->getViewAsIconIcon($ref); $view_color = $candidate_engine->getViewAsIconColor($ref); $loading = $candidate_engine->newLoadingContent($ref); $views[] = array( 'viewKey' => $candidate_engine->getDocumentEngineKey(), 'icon' => $view_icon, 'color' => $view_color, 'name' => $label, 'engineURI' => $this->newRefRenderURI($ref, $candidate_engine), 'viewURI' => $view_uri, 'loadingMarkup' => hsprintf('%s', $loading), 'canEncode' => $candidate_engine->canConfigureEncoding($ref), 'canHighlight' => $candidate_engine->canConfigureHighlighting($ref), 'canBlame' => $candidate_engine->canBlame($ref), ); } $viewport_id = celerity_generate_unique_node_id(); $control_id = celerity_generate_unique_node_id(); $icon = $engine->newDocumentIcon($ref); $config = array( 'controlID' => $control_id, ); $this->willStageRef($ref); if ($engine->shouldRenderAsync($ref)) { $content = $engine->newLoadingContent($ref); $config['next'] = 'render'; } else { $this->willRenderRef($ref); $content = $engine->newDocument($ref); if ($engine->canBlame($ref)) { $config['next'] = 'blame'; } } Javelin::initBehavior('document-engine', $config); $viewport = phutil_tag( 'div', array( 'id' => $viewport_id, ), $content); $meta = array( 'viewportID' => $viewport_id, 'viewKey' => $engine->getDocumentEngineKey(), 'views' => $views, 'encode' => array( 'icon' => 'fa-font', 'name' => pht('Change Text Encoding...'), 'uri' => '/services/encoding/', 'value' => $encode_setting, ), 'highlight' => array( 'icon' => 'fa-lightbulb-o', 'name' => pht('Highlight As...'), 'uri' => '/services/highlight/', 'value' => $highlight_setting, ), 'blame' => array( 'icon' => 'fa-backward', 'hide' => pht('Hide Blame'), 'show' => pht('Show Blame'), 'uri' => $ref->getBlameURI(), 'enabled' => $blame_setting, 'value' => null, ), 'coverage' => array( 'labels' => array( // TODO: Modularize this properly, see T13125. array( 'C' => pht('Covered'), 'U' => pht('Not Covered'), 'N' => pht('Not Executable'), 'X' => pht('Not Reachable'), ), ), ), ); $view_button = id(new PHUIButtonView()) ->setTag('a') ->setText(pht('View Options')) ->setIcon('fa-file-image-o') ->setColor(PHUIButtonView::GREY) ->setID($control_id) ->setMetadata($meta) ->setDropdown(true) ->addSigil('document-engine-view-dropdown'); $header = id(new PHUIHeaderView()) ->setHeaderIcon($icon) ->setHeader($ref->getName()) ->addActionLink($view_button); return id(new PHUIObjectBoxView()) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setHeader($header) ->appendChild($viewport); } final public function newRenderResponse(PhabricatorDocumentRef $ref) { $this->willStageRef($ref); $this->willRenderRef($ref); $request = $this->getRequest(); $viewer = $request->getViewer(); $engines = PhabricatorDocumentEngine::getEnginesForRef($viewer, $ref); $engine_key = $this->getSelectedDocumentEngineKey(); if (!isset($engines[$engine_key])) { return $this->newErrorResponse( pht( 'The engine ("%s") is unknown, or unable to render this document.', $engine_key)); } $engine = $engines[$engine_key]; $this->activeEngine = $engine; $encode_setting = $request->getStr('encode'); if (strlen($encode_setting)) { $engine->setEncodingConfiguration($encode_setting); } $highlight_setting = $request->getStr('highlight'); if (strlen($highlight_setting)) { $engine->setHighlightingConfiguration($highlight_setting); } $blame_setting = ($request->getStr('blame') !== 'off'); $engine->setBlameConfiguration($blame_setting); try { $content = $engine->newDocument($ref); } catch (Exception $ex) { return $this->newErrorResponse($ex->getMessage()); } return $this->newContentResponse($content); } public function newErrorResponse($message) { $container = phutil_tag( 'div', array( 'class' => 'document-engine-error', ), array( id(new PHUIIconView()) ->setIcon('fa-exclamation-triangle red'), ' ', $message, )); return $this->newContentResponse($container); } private function newContentResponse($content) { $request = $this->getRequest(); $viewer = $request->getViewer(); $controller = $this->getController(); if ($request->isAjax()) { return id(new AphrontAjaxResponse()) ->setContent( array( 'markup' => hsprintf('%s', $content), )); } $crumbs = $this->newCrumbs(); $crumbs->setBorder(true); $content_frame = id(new PHUIObjectBoxView()) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->appendChild($content); $page_frame = id(new PHUITwoColumnView()) ->setFooter($content_frame); $title = array(); $ref = $this->getRef(); if ($ref) { $title = array( $ref->getName(), pht('Standalone'), ); } else { $title = pht('Document'); } return $controller->newPage() ->setCrumbs($crumbs) ->setTitle($title) ->appendChild($page_frame); } protected function newCrumbs() { $engine = $this->getActiveEngine(); $controller = $this->getController(); $crumbs = $controller->buildApplicationCrumbsForEditEngine(); $ref = $this->getRef(); $this->addApplicationCrumbs($crumbs, $ref); if ($ref) { $label = $engine->getViewAsLabel($ref); if ($label) { $crumbs->addTextCrumb($label); } } return $crumbs; } + public function getRefViewURI( + PhabricatorDocumentRef $ref, + PhabricatorDocumentEngine $engine) { + return $this->newRefViewURI($ref, $engine); + } + abstract protected function newRefViewURI( PhabricatorDocumentRef $ref, PhabricatorDocumentEngine $engine); abstract protected function newRefRenderURI( PhabricatorDocumentRef $ref, PhabricatorDocumentEngine $engine); protected function getSelectedDocumentEngineKey() { return $this->getRequest()->getURIData('engineKey'); } protected function getSelectedLineRange() { return $this->getRequest()->getURILineRange('lines', 1000); } protected function addApplicationCrumbs( PHUICrumbsView $crumbs, PhabricatorDocumentRef $ref = null) { return; } protected function willStageRef(PhabricatorDocumentRef $ref) { return; } protected function willRenderRef(PhabricatorDocumentRef $ref) { return; } } diff --git a/src/applications/paste/controller/PhabricatorPasteViewController.php b/src/applications/paste/controller/PhabricatorPasteViewController.php index 9b4079fd46..b5736b784e 100644 --- a/src/applications/paste/controller/PhabricatorPasteViewController.php +++ b/src/applications/paste/controller/PhabricatorPasteViewController.php @@ -1,173 +1,230 @@ <?php final class PhabricatorPasteViewController extends PhabricatorPasteController { public function shouldAllowPublic() { return true; } public function handleRequest(AphrontRequest $request) { $viewer = $request->getViewer(); $id = $request->getURIData('id'); $paste = id(new PhabricatorPasteQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->needContent(true) ->needRawContent(true) ->executeOne(); if (!$paste) { return new Aphront404Response(); } $lines = $request->getURILineRange('lines', 1000); if ($lines) { $map = range($lines[0], $lines[1]); } else { $map = array(); } $header = $this->buildHeaderView($paste); $curtain = $this->buildCurtain($paste); $subheader = $this->buildSubheaderView($paste); $source_code = $this->buildSourceCodeView($paste, $map); require_celerity_resource('paste-css'); $monogram = $paste->getMonogram(); $crumbs = $this->buildApplicationCrumbs() ->addTextCrumb($monogram) ->setBorder(true); $timeline = $this->buildTransactionTimeline( $paste, new PhabricatorPasteTransactionQuery()); $comment_view = id(new PhabricatorPasteEditEngine()) ->setViewer($viewer) ->buildEditEngineCommentView($paste); $timeline->setQuoteRef($monogram); $comment_view->setTransactionTimeline($timeline); + $recommendation_view = $this->newDocumentRecommendationView($paste); + $paste_view = id(new PHUITwoColumnView()) ->setHeader($header) ->setSubheader($subheader) - ->setMainColumn(array( + ->setMainColumn( + array( + $recommendation_view, $source_code, $timeline, $comment_view, )) - ->setCurtain($curtain) - ->addClass('ponder-question-view'); + ->setCurtain($curtain); return $this->newPage() ->setTitle($paste->getFullName()) ->setCrumbs($crumbs) ->setPageObjectPHIDs( array( $paste->getPHID(), )) ->appendChild($paste_view); } private function buildHeaderView(PhabricatorPaste $paste) { $title = (nonempty($paste->getTitle())) ? $paste->getTitle() : pht('(An Untitled Masterwork)'); if ($paste->isArchived()) { $header_icon = 'fa-ban'; $header_name = pht('Archived'); $header_color = 'dark'; } else { $header_icon = 'fa-check'; $header_name = pht('Active'); $header_color = 'bluegrey'; } $header = id(new PHUIHeaderView()) ->setHeader($title) ->setUser($this->getRequest()->getUser()) ->setStatus($header_icon, $header_color, $header_name) ->setPolicyObject($paste) ->setHeaderIcon('fa-clipboard'); return $header; } private function buildCurtain(PhabricatorPaste $paste) { $viewer = $this->getViewer(); $curtain = $this->newCurtainView($paste); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $paste, PhabricatorPolicyCapability::CAN_EDIT); $id = $paste->getID(); $edit_uri = $this->getApplicationURI("edit/{$id}/"); $archive_uri = $this->getApplicationURI("archive/{$id}/"); $raw_uri = $this->getApplicationURI("raw/{$id}/"); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Paste')) ->setIcon('fa-pencil') ->setDisabled(!$can_edit) ->setHref($edit_uri)); if ($paste->isArchived()) { $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Activate Paste')) ->setIcon('fa-check') ->setDisabled(!$can_edit) ->setWorkflow($can_edit) ->setHref($archive_uri)); } else { $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Archive Paste')) ->setIcon('fa-ban') ->setDisabled(!$can_edit) ->setWorkflow($can_edit) ->setHref($archive_uri)); } $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('View Raw File')) ->setIcon('fa-file-text-o') ->setHref($raw_uri)); return $curtain; } private function buildSubheaderView( PhabricatorPaste $paste) { $viewer = $this->getViewer(); $author = $viewer->renderHandle($paste->getAuthorPHID())->render(); $date = phabricator_datetime($paste->getDateCreated(), $viewer); $author = phutil_tag('strong', array(), $author); $author_info = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withPHIDs(array($paste->getAuthorPHID())) ->needProfileImage(true) ->executeOne(); $image_uri = $author_info->getProfileImageURI(); $image_href = '/p/'.$author_info->getUsername(); $content = pht('Authored by %s on %s.', $author, $date); return id(new PHUIHeadThingView()) ->setImage($image_uri) ->setImageHref($image_href) ->setContent($content); } + private function newDocumentRecommendationView(PhabricatorPaste $paste) { + $viewer = $this->getViewer(); + + // See PHI1703. If a viewer is looking at a document in Paste which has + // a good rendering via a DocumentEngine, suggest they view the content + // in Files instead so they can see it rendered. + + $ref = id(new PhabricatorDocumentRef()) + ->setName($paste->getTitle()) + ->setData($paste->getRawContent()); + + $engines = PhabricatorDocumentEngine::getEnginesForRef($viewer, $ref); + if (!$engines) { + return null; + } + + $engine = head($engines); + if (!$engine->shouldSuggestEngine($ref)) { + return null; + } + + $file = id(new PhabricatorFileQuery()) + ->setViewer($viewer) + ->withPHIDs(array($paste->getFilePHID())) + ->executeOne(); + if (!$file) { + return null; + } + + $file_ref = id(new PhabricatorDocumentRef()) + ->setFile($file); + + $view_uri = id(new PhabricatorFileDocumentRenderingEngine()) + ->getRefViewURI($file_ref, $engine); + + $view_as_label = $engine->getViewAsLabel($file_ref); + + $view_as_hint = pht( + 'This content can be rendered as a document in Files.'); + + return id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) + ->addButton( + id(new PHUIButtonView()) + ->setTag('a') + ->setText($view_as_label) + ->setHref($view_uri) + ->setColor('grey')) + ->setErrors( + array( + $view_as_hint, + )); + } + }