diff --git a/conf/default.conf.php b/conf/default.conf.php index 8b837d9be6..00e0c36cda 100644 --- a/conf/default.conf.php +++ b/conf/default.conf.php @@ -1,560 +1,566 @@ null, // If you have multiple environments, provide the production environment URI // here so that emails, etc., generated in development/sandbox environments // contain the right links. 'phabricator.production-uri' => null, // Setting this to 'true' will invoke a special setup mode which helps guide // you through setting up Phabricator. 'phabricator.setup' => false, // The default PHID for users who haven't uploaded a profile image. It should // be 50x50px. 'user.default-profile-image-phid' => 'PHID-FILE-4d61229816cfe6f2b2a3', // -- DarkConsole ----------------------------------------------------------- // // DarkConsole is a administrative debugging/profiling tool built into // Phabricator. You can leave it disabled unless you're developing against // Phabricator. // Determines whether or not DarkConsole is available. DarkConsole exposes // some data like queries and stack traces, so you should be careful about // turning it on in production (although users can not normally see it, even // if the deployment configuration enables it). 'darkconsole.enabled' => false, // Always enable DarkConsole, even for logged out users. This potentially // exposes sensitive information to users, so make sure untrusted users can // not access an install running in this mode. You should definitely leave // this off in production. It is only really useful for using DarkConsole // utilties to debug or profile logged-out pages. You must set // 'darkconsole.enabled' to use this option. 'darkconsole.always-on' => false, // Allows you to mask certain configuration values from appearing in the // "Config" tab of DarkConsole. 'darkconsole.config-mask' => array( 'mysql.pass', 'amazon-ses.secret-key', 'recaptcha.private-key', 'phabricator.csrf-key', 'facebook.application-secret', 'github.application-secret', ), // -- MySQL --------------------------------------------------------------- // // The username to use when connecting to MySQL. 'mysql.user' => 'root', // The password to use when connecting to MySQL. 'mysql.pass' => '', // The MySQL server to connect to. If you want to connect to a different // port than the default (which is 3306), specify it in the hostname // (e.g., db.example.com:1234). 'mysql.host' => 'localhost', // -- Email ----------------------------------------------------------------- // // Some Phabricator tools send email notifications, e.g. when Differential // revisions are updated or Maniphest tasks are changed. These options allow // you to configure how email is delivered. // You can test your mail setup by going to "MetaMTA" in the web interface, // clicking "Send New Message", and then composing a message. // Default address to send mail "From". 'metamta.default-address' => 'noreply@example.com', // Domain used to generate Message-IDs. 'metamta.domain' => 'example.com', // When a user takes an action which generates an email notification (like // commenting on a Differential revision), Phabricator can either send that // mail "From" the user's email address (like "alincoln@logcabin.com") or // "From" the 'metamta.default-address' address. The user experience is // generally better if Phabricator uses the user's real address as the "From" // since the messages are easier to organize when they appear in mail clients, // but this will only work if the server is authorized to send email on behalf // of the "From" domain. Practically, this means: // - If you are doing an install for Example Corp and all the users will // have corporate @corp.example.com addresses and any hosts Phabricator // is running on are authorized to send email from corp.example.com, // you can enable this to make the user experience a little better. // - If you are doing an install for an open source project and your // users will be registering via Facebook and using personal email // addresses, you MUST NOT enable this or virtually all of your outgoing // email will vanish into SFP blackholes. // - If your install is anything else, you're much safer leaving this // off since the risk in turning it on is that your outgoing mail will // mostly never arrive. 'metamta.can-send-as-user' => false, // Adapter class to use to transmit mail to the MTA. The default uses // PHPMailerLite, which will invoke "sendmail". This is appropriate // if sendmail actually works on your host, but if you haven't configured mail // it may not be so great. You can also use Amazon SES, by changing this to // 'PhabricatorMailImplementationAmazonSESAdapter', signing up for SES, and // filling in your 'amazon-ses.access-key' and 'amazon-ses.secret-key' below. 'metamta.mail-adapter' => 'PhabricatorMailImplementationPHPMailerLiteAdapter', // When email is sent, try to hand it off to the MTA immediately. This may // be worth disabling if your MTA infrastructure is slow or unreliable. If you // disable this option, you must run the 'metamta_mta.php' daemon or mail // won't be handed off to the MTA. If you're using Amazon SES it can be a // little slugish sometimes so it may be worth disabling this and moving to // the daemon after you've got your install up and running. If you have a // properly configured local MTA it should not be necessary to disable this. 'metamta.send-immediately' => true, // If you're using Amazon SES to send email, provide your AWS access key // and AWS secret key here. To set up Amazon SES with Phabricator, you need // to: // - Make sure 'metamta.mail-adapter' is set to: // "PhabricatorMailImplementationAmazonSESAdapter" // - Make sure 'metamta.can-send-as-user' is false. // - Make sure 'metamta.default-address' is configured to something sensible. // - Make sure 'metamta.default-address' is a validated SES "From" address. 'amazon-ses.access-key' => null, 'amazon-ses.secret-key' => null, // If you're using Sendgrid to send email, provide your access credentials // here. This will use the REST API. You can also use Sendgrid as a normal // SMTP service. 'sendgrid.api-user' => null, 'sendgrid.api-key' => null, // You can configure a reply handler domain so that email sent from Maniphest // will have a special "Reply To" address like "T123+82+af19f@example.com" // that allows recipients to reply by email and interact with tasks. For // instructions on configurating reply handlers, see the article // "Configuring Inbound Email" in the Phabricator documentation. By default, // this is set to 'null' and Phabricator will use a generic 'noreply@' address // or the address of the acting user instead of a special reply handler // address (see 'metamta.default-address'). If you set a domain here, // Phabricator will begin generating private reply handler addresses. See // also 'metamta.maniphest.reply-handler' to further configure behavior. // This key should be set to the domain part after the @, like "example.com". 'metamta.maniphest.reply-handler-domain' => null, // You can follow the instructions in "Configuring Inbound Email" in the // Phabricator documentation and set 'metamta.maniphest.reply-handler-domain' // to support updating Maniphest tasks by email. If you want more advanced // customization than this provides, you can override the reply handler // class with an implementation of your own. This will allow you to do things // like have a single public reply handler or change how private reply // handlers are generated and validated. // This key should be set to a loadable subclass of // PhabricatorMailReplyHandler (and possibly of ManiphestReplyHandler). 'metamta.maniphest.reply-handler' => 'ManiphestReplyHandler', // Prefix prepended to mail sent by Maniphest. You can change this to // distinguish between testing and development installs, for example. 'metamta.maniphest.subject-prefix' => '[Maniphest]', // See 'metamta.maniphest.reply-handler-domain'. This does the same thing, // but allows email replies via Differential. 'metamta.differential.reply-handler-domain' => null, // See 'metamta.maniphest.reply-handler'. This does the same thing, but // affects Differential. 'metamta.differential.reply-handler' => 'DifferentialReplyHandler', // Prefix prepended to mail sent by Differential. 'metamta.differential.subject-prefix' => '[Differential]', // By default, Phabricator generates unique reply-to addresses and sends a // separate email to each recipient when you enable reply handling. This is // more secure than using "From" to establish user identity, but can mean // users may receive multiple emails when they are on mailing lists. Instead, // you can use a single, non-unique reply to address and authenticate users // based on the "From" address by setting this to 'true'. This trades away // a little bit of security for convenience, but it's reasonable in many // installs. Object interactions are still protected using hashes in the // single public email address, so objects can not be replied to blindly. 'metamta.public-replies' => false, // You can configure an email address like "bugs@phabricator.example.com" // which will automatically create Maniphest tasks when users send email // to it. This relies on the "From" address to authenticate users, so it is // is not completely secure. To set this up, enter a complete email // address like "bugs@phabricator.example.com" and then configure mail to // that address so it routed to Phabricator (if you've already configured // reply handlers, you're probably already done). See "Configuring Inbound // Email" in the documentation for more information. 'metamta.maniphest.public-create-email' => null, // -- Auth ------------------------------------------------------------------ // // Can users login with a username/password, or by following the link from // a password reset email? You can disable this and configure one or more // OAuth providers instead. 'auth.password-auth-enabled' => true, // Maximum number of simultaneous web sessions each user is permitted to have. // Setting this to "1" will prevent a user from logging in on more than one // browser at the same time. 'auth.sessions.web' => 5, // Maximum number of simultaneous Conduit sessions each user is permitted // to have. 'auth.sessions.conduit' => 3, // Set this true to enable the Settings -> SSH Public Keys panel, which will // allow users to associated SSH public keys with their accounts. This is only // really useful if you're setting up services over SSH and want to use // Phabricator for authentication; in most situations you can leave this // disabled. 'auth.sshkeys.enabled' => false, // -- Accounts -------------------------------------------------------------- // // Is basic account information (email, real name, profile picture) editable? // If you set up Phabricator to automatically synchronize account information // from some other authoritative system, you can disable this to ensure // information remains consistent across both systems. 'account.editable' => true, // -- Facebook ------------------------------------------------------------ // // Can users use Facebook credentials to login to Phabricator? 'facebook.auth-enabled' => false, // Can users use Facebook credentials to create new Phabricator accounts? 'facebook.registration-enabled' => true, // Are Facebook accounts permanently linked to Phabricator accounts, or can // the user unlink them? 'facebook.auth-permanent' => false, // The Facebook "Application ID" to use for Facebook API access. 'facebook.application-id' => null, // The Facebook "Application Secret" to use for Facebook API access. 'facebook.application-secret' => null, // -- Github ---------------------------------------------------------------- // // Can users use Github credentials to login to Phabricator? 'github.auth-enabled' => false, // Can users use Github credentials to create new Phabricator accounts? 'github.registration-enabled' => true, // Are Github accounts permanently linked to Phabricator accounts, or can // the user unlink them? 'github.auth-permanent' => false, // The Github "Client ID" to use for Github API access. 'github.application-id' => null, // The Github "Secret" to use for Github API access. 'github.application-secret' => null, // -- Recaptcha ------------------------------------------------------------- // // Is Recaptcha enabled? If disabled, captchas will not appear. 'recaptcha.enabled' => false, // Your Recaptcha public key, obtained from Recaptcha. 'recaptcha.public-key' => null, // Your Recaptcha private key, obtained from Recaptcha. 'recaptcha.private-key' => null, // -- Misc ------------------------------------------------------------------ // // This is hashed with other inputs to generate CSRF tokens. If you want, you // can change it to some other string which is unique to your install. This // will make your install more secure in a vague, mostly theoretical way. But // it will take you like 3 seconds of mashing on your keyboard to set it up so // you might as well. 'phabricator.csrf-key' => '0b7ec0592e0a2829d8b71df2fa269b2c6172eca3', // This is hashed with other inputs to generate mail tokens. If you want, you // can change it to some other string which is unique to your install. In // particular, you will want to do this if you accidentally send a bunch of // mail somewhere you shouldn't have, to invalidate all old reply-to // addresses. 'phabricator.mail-key' => '5ce3e7e8787f6e40dfae861da315a5cdf1018f12', // Version string displayed in the footer. You probably should leave this // alone. 'phabricator.version' => 'UNSTABLE', // PHP requires that you set a timezone in your php.ini before using date // functions, or it will emit a warning. If this isn't possible (for instance, // because you are using HPHP) you can set some valid constant for // date_default_timezone_set() here and Phabricator will set it on your // behalf, silencing the warning. 'phabricator.timezone' => null, // -- Files ----------------------------------------------------------------- // // Lists which uploaded file types may be viewed in the browser. If a file // has a mime type which does not appear in this list, it will always be // downloaded instead of displayed. This is a security consideration: if a // user uploads a file of type "text/html" and it is displayed as // "text/html", they can easily execute XSS attacks. This is also a usability // consideration, since browsers tend to freak out when viewing enormous // binary files. // // The keys in this array are viewable mime types; the values are the mime // types they will be delivered as when they are viewed in the browser. 'files.viewable-mime-types' => array( 'image/jpeg' => 'image/jpeg', 'image/jpg' => 'image/jpg', 'image/png' => 'image/png', 'image/gif' => 'image/gif', 'text/plain' => 'text/plain; charset=utf-8', ), // Phabricator can proxy images from other servers so you can paste the URI // to a funny picture of a cat into the comment box and have it show up as an // image. However, this means the webserver Phabricator is running on will // make HTTP requests to arbitrary URIs. If the server has access to internal // resources, this could be a security risk. You should only enable it if you // are installed entirely a VPN and VPN access is required to access // Phabricator, or if the webserver has no special access to anything. If // unsure, it is safer to leave this disabled. 'files.enable-proxy' => false, // -- Storage --------------------------------------------------------------- // // Phabricator allows users to upload files, and can keep them in various // storage engines. This section allows you to configure which engines // Phabricator will use, and how it will use them. // The largest filesize Phabricator will store in the MySQL BLOB storage // engine, which just uses a database table to store files. While this isn't a // best practice, it's really easy to set up. This is hard-limited by the // value of 'max_allowed_packet' in MySQL (since this often defaults to 1MB, // the default here is slightly smaller than 1MB). Set this to 0 to disable // use of the MySQL blob engine. 'storage.mysql-engine.max-size' => 1000000, // Phabricator provides a local disk storage engine, which just writes files // to some directory on local disk. The webserver must have read/write // permissions on this directory. This is straightforward and suitable for // most installs, but will not scale past one web frontend unless the path // is actually an NFS mount, since you'll end up with some of the files // written to each web frontend and no way for them to share. To use the // local disk storage engine, specify the path to a directory here. To // disable it, specify null. 'storage.local-disk.path' => null, // TODO: Implement S3. // Phabricator uses a storage engine selector to choose which storage engine // to use when writing file data. If you add new storage engines or want to // provide very custom rules (e.g., write images to one storage engine and // other files to a different one), you can provide an alternate // implementation here. The default engine will use choose MySQL, Local Disk, // and S3, in that order, if they have valid configurations above and a file // fits within configured limits. 'storage.engine-selector' => 'PhabricatorDefaultFileStorageEngineSelector', // -- Differential ---------------------------------------------------------- // 'differential.revision-custom-detail-renderer' => null, // Array for custom remarkup rules. The array should have a list of // class names of classes that extend PhutilRemarkupRule 'differential.custom-remarkup-rules' => null, // Array for custom remarkup block rules. The array should have a list of // class names of classes that extend PhutilRemarkupEngineBlockRule 'differential.custom-remarkup-block-rules' => null, // Set display word-wrap widths for Differential. Specify a dictionary of // regular expressions mapping to column widths. The filename will be matched // against each regexp in order until one matches. The default configuration // uses a width of 100 for Java and 80 for other languages. Note that 80 is // the greatest column width of all time. Changes here will not be immediately // reflected in old revisions unless you purge the render cache. 'differential.wordwrap' => array( '/\.java$/' => 100, '/.*/' => 80, ), // Class for appending custom fields to be included in the commit // messages generated by "arc amend". Should inherit // DifferentialCommitMessageModifier 'differential.modify-commit-message-class' => null, + // List of file regexps were whitespace is meaningful and should not + // use 'ignore-all' by default + 'differential.whitespace-matters' => array( + '/\.py$/', + ), + // -- Maniphest ------------------------------------------------------------- // 'maniphest.enabled' => true, // -- Remarkup -------------------------------------------------------------- // // If you enable this, linked YouTube videos will be embeded inline. This has // mild security implications (you'll leak referrers to YouTube) and is pretty // silly (but sort of awesome). 'remarkup.enable-embedded-youtube' => false, // -- Garbage Collection ---------------------------------------------------- // // Phabricator generates various logs and caches in the database which can // be garbage collected after a while to make the total data size more // manageable. To run garbage collection, launch a // PhabricatorGarbageCollector daemon. // Since the GC daemon can issue large writes and table scans, you may want to // run it only during off hours or make sure it is scheduled so it doesn't // overlap with backups. This determines when the daemon can start running // each day. 'gcdaemon.run-at' => '12 AM', // How many seconds after 'gcdaemon.run-at' the daemon may collect garbage // for. By default it runs continuously, but you can set it to run for a // limited period of time. For instance, if you do backups at 3 AM, you might // run garbage collection for an hour beforehand. This is not a high-precision // limit so you may want to leave some room for the GC to actually stop, and // if you set it to something like 3 seconds you're on your own. 'gcdaemon.run-for' => 24 * 60 * 60, // These 'ttl' keys configure how much old data the GC daemon keeps around. // Objects older than the ttl will be collected. Set any value to 0 to store // data indefinitely. 'gcdaemon.ttl.herald-transcripts' => 30 * (24 * 60 * 60), 'gcdaemon.ttl.daemon-logs' => 7 * (24 * 60 * 60), 'gcdaemon.ttl.differential-parse-cache' => 14 * (24 * 60 * 60), // -- Feed ------------------------------------------------------------------ // // If you set this to true, you can embed Phabricator activity feeds in other // pages using iframes. These feeds are completely public, and a login is not // required to view them! This is intended for things like open source // projects that want to expose an activity feed on the project homepage. 'feed.public' => false, // -- Customization --------------------------------------------------------- // // Paths to additional phutil libraries to load. 'load-libraries' => array(), 'aphront.default-application-configuration-class' => 'AphrontDefaultApplicationConfiguration', 'controller.oauth-registration' => 'PhabricatorOAuthDefaultRegistrationController', // Directory that phd (the Phabricator daemon control script) should use to // track running daemons. 'phd.pid-directory' => '/var/tmp/phd', // This value is an input to the hash function when building resource hashes. // It has no security value, but if you accidentally poison user caches (by // pushing a bad patch or having something go wrong with a CDN, e.g.) you can // change this to something else and rebuild the Celerity map to break user // caches. Unless you are doing Celerity development, it is exceptionally // unlikely that you need to modify this. 'celerity.resource-hash' => 'd9455ea150622ee044f7931dabfa52aa', // In a development environment, it is desirable to force static resources // (CSS and JS) to be read from disk on every request, so that edits to them // appear when you reload the page even if you haven't updated the resource // maps. This setting ensures requests will be verified against the state on // disk. Generally, you should leave this off in production (caching behavior // and performance improve with it off) but turn it on in development. (These // settings are the defaults.) 'celerity.force-disk-reads' => false, // -- Pygments ------------------------------------------------------------ // // Phabricator can highlight PHP by default, but if you want syntax // highlighting for other languages you should install the python package // 'Pygments', make sure the 'pygmentize' script is available in the // $PATH of the webserver, and then enable this. 'pygments.enabled' => false, // In places that we display a dropdown to syntax-highlight code, // this is where that list is defined. // Syntax is 'lexer-name' => 'Display Name', 'pygments.dropdown-choices' => array( 'apacheconf' => 'Apache Configuration', 'bash' => 'Bash Scripting', 'brainfuck' => 'Brainf*ck', 'c' => 'C', 'cpp' => 'C++', 'css' => 'CSS', 'diff' => 'Diff', 'django' => 'Django Templating', 'erb' => 'Embedded Ruby/ERB', 'erlang' => 'Erlang', 'html' => 'HTML', 'infer' => 'Infer from title (extension)', 'java' => 'Java', 'js' => 'Javascript', 'mysql' => 'MySQL', 'perl' => 'Perl', 'php' => 'PHP', 'text' => 'Plain Text', 'python' => 'Python', // TODO: 'remarkup' => 'Remarkup', 'ruby' => 'Ruby', 'xml' => 'XML', ), 'pygments.dropdown-default' => 'infer', // This is an override list of regular expressions which allows you to choose // what language files are highlighted as. If your projects have certain rules // about filenames or use unusual or ambiguous language extensions, you can // create a mapping here. This is an ordered dictionary of regular expressions // which will be tested against the filename. They should map to either an // explicit language as a string value, or a numeric index into the captured // groups as an integer. 'syntax.filemap' => array( // Example: Treat all '*.xyz' files as PHP. // '@\\.xyz$@' => 'php', // Example: Treat 'httpd.conf' as 'apacheconf'. // '@/httpd\\.conf$@' => 'apacheconf', // Example: Treat all '*.x.bak' file as '.x'. NOTE: we map to capturing // group 1 by specifying the mapping as "1". // '@\\.([^.]+)\\.bak$@' => 1, ), ); diff --git a/src/applications/differential/parser/changeset/DifferentialChangesetParser.php b/src/applications/differential/parser/changeset/DifferentialChangesetParser.php index 8e4b24b4ac..9d1b39d4a2 100644 --- a/src/applications/differential/parser/changeset/DifferentialChangesetParser.php +++ b/src/applications/differential/parser/changeset/DifferentialChangesetParser.php @@ -1,1622 +1,1626 @@ 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; } /** * 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; } /** * Set the character width at which lines will be wrapped. Defaults to 80. * * @param int Hard-wrap line-width for diff display. * @return this */ public function setLineWidth($width) { $this->lineWidth = $width; return $this; } private function getRenderCacheKey() { return $this->renderCacheKey; } public function setChangeset($changeset) { $this->changeset = $changeset; $this->setFilename($changeset->getFilename()); $this->setLineWidth($changeset->getWordWrapWidth()); return $this; } public function setWhitespaceMode($whitespace_mode) { $this->whitespaceMode = $whitespace_mode; return $this; } public function setRenderingReference($ref) { $this->renderingReference = $ref; return $this; } public function getChangeset() { return $this->changeset; } public function setFilename($filename) { $this->filename = $filename; return $this; } public function setHandles(array $handles) { $this->handles = $handles; return $this; } public function setMarkupEngine(PhutilMarkupEngine $engine) { $this->markupEngine = $engine; return $this; } public function setUser(PhabricatorUser $user) { $this->user = $user; return $this; } public function parseHunk(DifferentialHunk $hunk) { $this->parsedHunk = true; $lines = $hunk->getChanges(); $lines = str_replace( array("\t", "\r\n", "\r"), array(' ', "\n", "\n"), $lines); $lines = explode("\n", $lines); $types = array(); foreach ($lines as $line_index => $line) { if (isset($line[0])) { $char = $line[0]; if ($char == ' ') { $types[$line_index] = null; } else if ($char == '\\' && $line_index > 0) { $types[$line_index] = $types[$line_index - 1]; } else { $types[$line_index] = $char; } } else { $types[$line_index] = null; } } $old_line = $hunk->getOldOffset(); $new_line = $hunk->getNewOffset(); $num_lines = count($lines); if ($old_line > 1) { $this->missingOld[$old_line] = true; } else if ($new_line > 1) { $this->missingNew[$new_line] = true; } for ($cursor = 0; $cursor < $num_lines; $cursor++) { $type = $types[$cursor]; $data = array( 'type' => $type, 'text' => (string)substr($lines[$cursor], 1), 'line' => $new_line, ); switch ($type) { case '+': $this->new[] = $data; ++$new_line; break; case '-': $data['line'] = $old_line; $this->old[] = $data; ++$old_line; break; default: $this->new[] = $data; $data['line'] = $old_line; $this->old[] = $data; ++$new_line; ++$old_line; break; } } } public function getDisplayLine($offset, $length) { $start = 1; for ($ii = $offset; $ii > 0; $ii--) { if ($this->new[$ii] && $this->new[$ii]['line']) { $start = $this->new[$ii]['line']; break; } } $end = $start; for ($ii = $offset + $length; $ii < count($this->new); $ii++) { if ($this->new[$ii] && $this->new[$ii]['line']) { $end = $this->new[$ii]['line']; break; } } return "{$start},{$end}"; } public function parseInlineComment(DifferentialInlineComment $comment) { // Parse only comments which are actually visible. if ($this->isCommentVisibleOnRenderedDiff($comment)) { $this->comments[] = $comment; } return $this; } public function process() { $old = array(); $new = array(); $n = 0; $this->old = array_reverse($this->old); $this->new = array_reverse($this->new); $whitelines = false; $changed = false; $skip_intra = array(); while (count($this->old) || count($this->new)) { $o_desc = array_pop($this->old); $n_desc = array_pop($this->new); $oend = end($this->old); if ($oend) { $o_next = $oend['type']; } else { $o_next = null; } $nend = end($this->new); if ($nend) { $n_next = $nend['type']; } else { $n_next = null; } if ($o_desc) { $o_type = $o_desc['type']; } else { $o_type = null; } if ($n_desc) { $n_type = $n_desc['type']; } else { $n_type = null; } if (($o_type != null) && ($n_type == null)) { $old[] = $o_desc; $new[] = null; if ($n_desc) { array_push($this->new, $n_desc); } $changed = true; continue; } if (($n_type != null) && ($o_type == null)) { $old[] = null; $new[] = $n_desc; if ($o_desc) { array_push($this->old, $o_desc); } $changed = true; continue; } if ($this->whitespaceMode != self::WHITESPACE_SHOW_ALL) { $similar = false; switch ($this->whitespaceMode) { case self::WHITESPACE_IGNORE_TRAILING: if (rtrim($o_desc['text']) == rtrim($n_desc['text'])) { if ($o_desc['type']) { // If we're converting this into an unchanged line because of // a trailing whitespace difference, mark it as a whitespace // change so we can show "This file was modified only by // adding or removing trailing whitespace." instead of // "This file was not modified.". $whitelines = true; } $similar = true; } break; default: // In this case, the lines are similar if there is no change type // (that is, just trust the diff algorithm). if (!$o_desc['type']) { $similar = true; } break; } if ($similar) { $o_desc['type'] = null; $n_desc['type'] = null; $skip_intra[count($old)] = true; } else { $changed = true; } } else { $changed = true; } $old[] = $o_desc; $new[] = $n_desc; } $this->old = $old; $this->new = $new; $unchanged = false; if ($this->subparser) { $unchanged = $this->subparser->isUnchanged(); $whitelines = $this->subparser->isWhitespaceOnly(); } else if (!$changed) { $filetype = $this->changeset->getFileType(); if ($filetype == DifferentialChangeType::FILE_TEXT || $filetype == DifferentialChangeType::FILE_SYMLINK) { $unchanged = true; } } $this->specialAttributes = array( self::ATTR_UNCHANGED => $unchanged, self::ATTR_DELETED => array_filter($this->old) && !array_filter($this->new), self::ATTR_WHITELINES => $whitelines ); if ($this->isSubparser) { // The rest of this function deals with formatting the diff for display; // we can exit early if we're a subparser and avoid doing extra work. return; } if ($this->subparser) { // Use this parser's side-by-side line information -- notably, the // change types -- but replace all the line text with the subparser's. // This lets us render whitespace-only changes without marking them as // different. $old = $this->old; $new = $this->new; $old_text = ipull($this->subparser->old, 'text', 'line'); $new_text = ipull($this->subparser->new, 'text', 'line'); foreach ($old as $k => $desc) { if (empty($desc)) { continue; } $old[$k]['text'] = idx($old_text, $desc['line']); } foreach ($new as $k => $desc) { if (empty($desc)) { continue; } $new[$k]['text'] = idx($new_text, $desc['line']); // If there's a corresponding "old" text and the line is marked as // unchanged, test if there are internal whitespace changes between // non-whitespace characters, e.g. spaces added to a string or spaces // added around operators. If we find internal spaces, mark the line // as changed. // // We only need to do this for "new" lines because any line that is // missing either "old" or "new" text certainly can not have internal // whitespace changes without also having non-whitespace changes, // because characters had to be either added or removed to create the // possibility of internal whitespace. if (isset($old[$k]['text']) && empty($new[$k]['type'])) { if (trim($old[$k]['text']) != trim($new[$k]['text'])) { // The strings aren't the same when trimmed, so there are internal // whitespace changes. Mark this line changed. $old[$k]['type'] = '-'; $new[$k]['type'] = '+'; } } } $this->old = $old; $this->new = $new; } $min_length = min(count($this->old), count($this->new)); for ($ii = 0; $ii < $min_length; $ii++) { if ($this->old[$ii] || $this->new[$ii]) { if (isset($this->old[$ii]['text'])) { $otext = $this->old[$ii]['text']; } else { $otext = ''; } if (isset($this->new[$ii]['text'])) { $ntext = $this->new[$ii]['text']; } else { $ntext = ''; } if ($otext != $ntext && empty($skip_intra[$ii])) { $this->intra[$ii] = ArcanistDiffUtils::generateIntralineDiff( $otext, $ntext); } } } $lines_context = self::LINES_CONTEXT; $max_length = max(count($this->old), count($this->new)); $old = $this->old; $new = $this->new; $visible = false; $last = 0; for ($cursor = -$lines_context; $cursor < $max_length; $cursor++) { $offset = $cursor + $lines_context; if ((isset($old[$offset]) && $old[$offset]['type']) || (isset($new[$offset]) && $new[$offset]['type'])) { $visible = true; $last = $offset; } else if ($cursor > $last + $lines_context) { $visible = false; } if ($visible && $cursor > 0) { $this->visible[$cursor] = 1; } } // NOTE: Micro-optimize a couple of ipull()s here since it gives us a // 10% performance improvement for certain types of large diffs like // Phriction changes. $old_corpus = array(); foreach ($this->old as $o) { $old_corpus[] = $o['text']; } $old_corpus_block = implode("\n", $old_corpus); $new_corpus = array(); foreach ($this->new as $n) { $new_corpus[] = $n['text']; } $new_corpus_block = implode("\n", $new_corpus); $old_future = $this->getHighlightFuture($old_corpus_block); $new_future = $this->getHighlightFuture($new_corpus_block); $futures = array( 'old' => $old_future, 'new' => $new_future, ); foreach (Futures($futures) as $key => $future) { try { switch ($key) { case 'old': $this->oldRender = $this->processHighlightedSource( $this->old, $future->resolve()); break; case 'new': $this->newRender = $this->processHighlightedSource( $this->new, $future->resolve()); 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); $generated = (strpos($new_corpus_block, '@'.'generated') !== false); $this->specialAttributes[self::ATTR_GENERATED] = $generated; } public function loadCache() { $render_cache_key = $this->getRenderCacheKey(); if (!$render_cache_key) { return false; } $data = null; $changeset = new DifferentialChangeset(); $conn_r = $changeset->establishConnection('r'); $data = queryfx_one( $conn_r, 'SELECT * FROM %T WHERE id = %d', $changeset->getTableName().'_parse_cache', $render_cache_key); if (!$data) { return false; } $data = json_decode($data['cache'], true); if (!is_array($data) || !$data) { return false; } foreach (self::getCacheableProperties() as $cache_key) { if (!array_key_exists($cache_key, $data)) { // If we're missing a cache key, assume we're looking at an old cache // and ignore it. return false; } } if ($data['cacheVersion'] !== self::CACHE_VERSION) { return false; } 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', 'missingOld', 'missingNew', 'cacheVersion', 'cacheHost', ); } public function saveCache() { $render_cache_key = $this->getRenderCacheKey(); if (!$render_cache_key) { return false; } $cache = array(); foreach (self::getCacheableProperties() as $cache_key) { switch ($cache_key) { case 'cacheVersion': $cache[$cache_key] = self::CACHE_VERSION; break; case 'cacheHost': $cache[$cache_key] = php_uname('n'); break; default: $cache[$cache_key] = $this->$cache_key; break; } } $cache = json_encode($cache); try { $changeset = new DifferentialChangeset(); $conn_w = $changeset->establishConnection('w'); queryfx( $conn_w, 'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %s, %d) ON DUPLICATE KEY UPDATE cache = VALUES(cache)', DifferentialChangeset::TABLE_CACHE, $render_cache_key, $cache, time()); } catch (AphrontQueryException $ex) { // TODO: uhoh } } 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 getLength() { return max(count($this->old), count($this->new)); } protected function applyIntraline(&$render, $intra, $corpus) { foreach ($render as $key => $text) { if (isset($intra[$key])) { $render[$key] = ArcanistDiffUtils::applyIntralineDiff( $text, $intra[$key]); } if (isset($corpus[$key]) && strlen($corpus[$key]) > $this->lineWidth) { $render[$key] = $this->lineWrap($render[$key]); } } } /** * Hard-wrap a piece of UTF-8 text with embedded HTML tags and entities. * * @param string An HTML string with tags and entities. * @return string Hard-wrapped string. */ protected function lineWrap($line) { $c = 0; $break_here = array(); // Convert the UTF-8 string into a list of UTF-8 characters. $vector = phutil_utf8v($line); $len = count($vector); $byte_pos = 0; for ($ii = 0; $ii < $len; ++$ii) { // An ampersand indicates an HTML entity; consume the whole thing (until // ";") but treat it all as one character. if ($vector[$ii] == '&') { do { ++$ii; } while ($vector[$ii] != ';'); ++$c; // An "<" indicates an HTML tag, consume the whole thing but don't treat // it as a character. } else if ($vector[$ii] == '<') { do { ++$ii; } while ($vector[$ii] != '>'); } else { ++$c; } // Keep track of where we need to break the string later. if ($c == $this->lineWidth) { $break_here[$ii] = true; $c = 0; } } $result = array(); foreach ($vector as $ii => $char) { $result[] = $char; if (isset($break_here[$ii])) { $result[] = "\xE2\xAC\x85
"; } } return implode('', $result); } protected function getHighlightFuture($corpus) { return $this->highlightEngine->getHighlightFuture( $this->highlightEngine->getLanguageFromFilename($this->filename), $corpus); } protected function processHighlightedSource($data, $result) { $result_lines = explode("\n", $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: break; default: $whitespace_mode = self::WHITESPACE_IGNORE_ALL; break; } $skip_cache = ($whitespace_mode != self::WHITESPACE_IGNORE_ALL); $this->whitespaceMode = $whitespace_mode; $changeset = $this->changeset; if ($changeset->getFileType() == DifferentialChangeType::FILE_TEXT || $changeset->getFileType() == DifferentialChangeType::FILE_SYMLINK) { if ($skip_cache || !$this->loadCache()) { $ignore_all = ($this->whitespaceMode == self::WHITESPACE_IGNORE_ALL); + 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; } } if ($ignore_all) { // Huge mess. Generate a "-bw" (ignore all whitespace changes) diff, // parse it out, and then play a shell game with the parsed format // in process() so we highlight only changed lines but render // whitespace differences. If we don't do this, we either fail to // render whitespace changes (which is incredibly confusing, // especially for python) or often produce a much larger set of // differences than necessary. $engine = new PhabricatorDifferenceEngine(); $engine->setIgnoreWhitespace(true); $no_whitespace_changeset = $engine->generateChangesetFromFileContent( $old_file, $new_file); // subparser takes over the current non-whitespace-ignoring changeset $subparser = new DifferentialChangesetParser(); $subparser->isSubparser = true; $subparser->setChangeset($changeset); foreach ($changeset->getHunks() as $hunk) { $subparser->parseHunk($hunk); } // We need to call process() so that the subparser's values for // metadata (like 'unchanged') is correct. $subparser->process(); $this->subparser = $subparser; // While we aren't updating $this->changeset (since it has a bunch // of metadata we need to preserve, so that headers like "this file // was moved" render correctly), we're overwriting the local // $changeset so that the block below will choose the synthetic // hunks we've built instead of the original hunks. $changeset = $no_whitespace_changeset; } // This either uses the real hunks, or synthetic hunks we built above. foreach ($changeset->getHunks() as $hunk) { $this->parseHunk($hunk); } $this->process(); if (!$skip_cache) { $this->saveCache(); } } } } public function render( $range_start = null, $range_len = null, $mask_force = array()) { // "Top level" renders are initial requests for the whole file, versus // requests for a specific range generated by clicking "show more". We // generate property changes and "shield" UI elements only for toplevel // requests. $this->isTopLevel = (($range_start === null) && ($range_len === null)); $this->highlightEngine = PhabricatorSyntaxHighlighter::newEngine(); $this->tryCacheStuff(); $feedback_mask = array(); switch ($this->changeset->getFileType()) { case DifferentialChangeType::FILE_IMAGE: $old = null; $cur = null; $metadata = $this->changeset->getMetadata(); $data = idx($metadata, 'attachment-data'); $old_phid = idx($metadata, 'old:binary-phid'); $new_phid = idx($metadata, 'new:binary-phid'); if ($old_phid || $new_phid) { if ($old_phid) { $old_uri = PhabricatorFileURI::getViewURIForPHID($old_phid); $old = phutil_render_tag( 'img', array( 'src' => $old_uri, )); } if ($new_phid) { $new_uri = PhabricatorFileURI::getViewURIForPHID($new_phid); $cur = phutil_render_tag( 'img', array( 'src' => $new_uri, )); } } $output = $this->renderChangesetTable( $this->changeset, ''. ''. ''. '
'. $old. '
'. ''. ''. ''. '
'. $cur. '
'. ''. ''); return $output; case DifferentialChangeType::FILE_DIRECTORY: case DifferentialChangeType::FILE_BINARY: $output = $this->renderChangesetTable($this->changeset, null); return $output; } $shield = null; if ($this->isTopLevel && !$this->comments) { if ($this->isGenerated()) { $shield = $this->renderShield( "This file contains generated code, which does not normally need ". "to be reviewed.", true); } else if ($this->isUnchanged()) { if ($this->isWhitespaceOnly()) { $shield = $this->renderShield( "This file was changed only by adding or removing trailing ". "whitespace.", false); } else { $shield = $this->renderShield( "The contents of this file were not changed.", false); } } else if ($this->isDeleted()) { $shield = $this->renderShield( "This file was completely deleted.", true); } else if ($this->changeset->getAffectedLineCount() > 2500) { $lines = number_format($this->changeset->getAffectedLineCount()); $shield = $this->renderShield( "This file has a very large number of changes ({$lines} lines).", true); } } if ($shield) { return $this->renderChangesetTable($this->changeset, $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 = $this->isCommentOnRightSideWhenDisplayed($comment); for ($ii = $start; $ii <= $end; $ii++) { if ($new) { $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(); if ($this->isCommentOnRightSideWhenDisplayed($comment)) { $new_comments[$final][] = $comment; } else { $old_comments[$final][] = $comment; } } } $html = $this->renderTextChange( $range_start, $range_len, $mask_force, $feedback_mask, $old_comments, $new_comments); return $this->renderChangesetTable($this->changeset, $html); } /** * Determine if an inline comment will appear on the rendered diff, * taking into consideration which halves of which changesets will actually * be shown. * * @param DifferentialInlineComment Comment to test for visibility. * @return bool True if the comment is visible on the rendered diff. */ private function isCommentVisibleOnRenderedDiff( DifferentialInlineComment $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 DifferentialInlineComment Comment to test for display location. * @return bool True for right, false for left. */ private function isCommentOnRightSideWhenDisplayed( DifferentialInlineComment $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; } protected function renderShield($message, $more) { if ($more) { $end = $this->getLength(); $reference = $this->renderingReference; $more = ' '. javelin_render_tag( 'a', array( 'mustcapture' => true, 'sigil' => 'show-more', 'class' => 'complete', 'href' => '#', 'meta' => array( 'ref' => $reference, 'range' => "0-{$end}", ), ), 'Show File Contents'); } else { $more = null; } return javelin_render_tag( 'tr', array( 'sigil' => 'context-target', ), ''. phutil_escape_html($message). $more. ''); } protected function renderTextChange( $range_start, $range_len, $mask_force, $feedback_mask, array $old_comments, array $new_comments) { $context_not_available = null; if ($this->missingOld || $this->missingNew) { $context_not_available = javelin_render_tag( 'tr', array( 'sigil' => 'context-target', ), ''. 'Context not available.'. ''); $context_not_available = $context_not_available; } $html = array(); $rows = max( count($this->old), count($this->new)); if ($range_start === null) { $range_start = 0; } if ($range_len === null) { $range_len = $rows; } $range_len = min($range_len, $rows - $range_start); // Gaps - compute gaps in the visible display diff, where we will render // "Show more context" spacers. This builds an aggregate $mask of all the // lines we must show (because they are near changed lines, near inline // comments, or the request has explicitly asked for them, i.e. resulting // from the user clicking "show more") and then finds all the gaps between // visible lines. 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. $gaps = array(); $gap_start = 0; $in_gap = false; $mask = $this->visible + $mask_force + $feedback_mask; $mask[$range_start + $range_len] = true; for ($ii = $range_start; $ii <= $range_start + $range_len; $ii++) { if (isset($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++) { $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); $reference = $this->renderingReference; $left_id = $this->leftSideChangesetID; $right_id = $this->rightSideChangesetID; // "N" stands for 'new' and means the comment should attach to the new file // when stored, i.e. DifferentialInlineComment->setIsNewFile(). // "O" stands for 'old' and means the comment should attach to the old file. $left_char = $this->leftSideAttachesToNewFile ? 'N' : 'O'; $right_char = $this->rightSideAttachesToNewFile ? 'N' : 'O'; for ($ii = $range_start; $ii < $range_start + $range_len; $ii++) { if (empty($mask[$ii])) { // If we aren't going to show this line, we've just entered a gap. // Pop information about the next gap off the $gaps stack and render // an appropriate "Show more context" element. This branch eventually // increments $ii by the entire size of the gap and then continues // the loop. $gap = array_pop($gaps); $top = $gap[0]; $len = $gap[1]; $end = $top + $len - 20; $contents = array(); if ($len > 40) { $contents[] = javelin_render_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'sigil' => 'show-more', 'meta' => array( 'ref' => $reference, 'range' => "{$top}-{$len}/{$top}-20", ), ), "\xE2\x96\xB2 Show 20 Lines"); } $contents[] = javelin_render_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'sigil' => 'show-more', 'meta' => array( 'ref' => $reference, 'range' => "{$top}-{$len}/{$top}-{$len}", ), ), 'Show All '.$len.' Lines'); if ($len > 40) { $contents[] = javelin_render_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'sigil' => 'show-more', 'meta' => array( 'ref' => $reference, 'range' => "{$top}-{$len}/{$end}-20", ), ), "\xE2\x96\xBC Show 20 Lines"); }; $container = javelin_render_tag( 'tr', array( 'sigil' => 'context-target', ), ''. implode(' • ', $contents). ''); $html[] = $container; $ii += ($len - 1); continue; } if (isset($this->old[$ii])) { $o_num = $this->old[$ii]['line']; $o_text = isset($this->oldRender[$ii]) ? $this->oldRender[$ii] : null; $o_attr = null; if ($this->old[$ii]['type']) { if (empty($this->new[$ii])) { $o_attr = ' class="old old-full"'; } else { $o_attr = ' class="old"'; } } } else { $o_num = null; $o_text = null; $o_attr = null; } if (isset($this->new[$ii])) { $n_num = $this->new[$ii]['line']; $n_text = isset($this->newRender[$ii]) ? $this->newRender[$ii] : null; $n_attr = null; if ($this->new[$ii]['type']) { if (empty($this->old[$ii])) { $n_attr = ' class="new new-full"'; } else { $n_attr = ' class="new"'; } } } else { $n_num = null; $n_text = null; $n_attr = null; } if (($o_num && !empty($this->missingOld[$o_num])) || ($n_num && !empty($this->missingNew[$n_num]))) { $html[] = $context_not_available; } if ($o_num && $left_id) { $o_id = ' id="C'.$left_id.$left_char.'L'.$o_num.'"'; } else { $o_id = null; } if ($n_num && $right_id) { $n_id = ' id="C'.$right_id.$right_char.'L'.$n_num.'"'; } else { $n_id = null; } // NOTE: The Javascript is sensitive to whitespace changes in this // block! $html[] = ''. ''.$o_num.''. ''.$o_text.''. ''.$n_num.''. ''.$n_text.''. ''; if ($context_not_available && ($ii == $rows - 1)) { $html[] = $context_not_available; } if ($o_num && isset($old_comments[$o_num])) { foreach ($old_comments[$o_num] as $comment) { $xhp = $this->renderInlineComment($comment); $html[] = ''. $xhp. ''; } } if ($n_num && isset($new_comments[$n_num])) { foreach ($new_comments[$n_num] as $comment) { $xhp = $this->renderInlineComment($comment); $html[] = ''. $xhp. ''; } } } return implode('', $html); } private function renderInlineComment(DifferentialInlineComment $comment) { $user = $this->user; $edit = $user && ($comment->getAuthorPHID() == $user->getPHID()) && (!$comment->getCommentID()); $on_right = $this->isCommentOnRightSideWhenDisplayed($comment); return id(new DifferentialInlineCommentView()) ->setInlineComment($comment) ->setOnRight($on_right) ->setHandles($this->handles) ->setMarkupEngine($this->markupEngine) ->setEditable($edit) ->render(); } protected function renderPropertyChangeHeader($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 null; } $old = $changeset->getOldProperties(); $new = $changeset->getNewProperties(); if ($old === $new) { return null; } if ($changeset->getChangeType() == DifferentialChangeType::TYPE_ADD && $new == array('unix:filemode' => '100644')) { return null; } if ($changeset->getChangeType() == DifferentialChangeType::TYPE_DELETE && $old == array('unix:filemode' => '100644')) { return null; } $keys = array_keys($old + $new); sort($keys); $rows = array(); foreach ($keys as $key) { $oval = idx($old, $key); $nval = idx($new, $key); if ($oval !== $nval) { if ($oval === null) { $oval = 'null'; } else { $oval = phutil_escape_html($oval); } if ($nval === null) { $nval = 'null'; } else { $nval = phutil_escape_html($nval); } $rows[] = ''. ''.phutil_escape_html($key).''. ''.$oval.''. ''.$nval.''. ''; } } return ''. ''. ''. ''. ''. ''. implode('', $rows). '
Property ChangesOld ValueNew Value
'; } protected function renderChangesetTable($changeset, $contents) { $props = $this->renderPropertyChangeHeader($this->changeset); $table = null; if ($contents) { $table = ''. $contents. '
'; } if (!$table && !$props) { $notice = $this->renderChangeTypeHeader($this->changeset, true); } else { $notice = $this->renderChangeTypeHeader($this->changeset, false); } return implode( "\n", array( $notice, $props, $table, )); } protected function renderChangeTypeHeader($changeset, $force) { static $articles = array( DifferentialChangeType::FILE_IMAGE => 'an', ); static $files = array( DifferentialChangeType::FILE_TEXT => 'file', DifferentialChangeType::FILE_IMAGE => 'image', DifferentialChangeType::FILE_DIRECTORY => 'directory', DifferentialChangeType::FILE_BINARY => 'binary file', DifferentialChangeType::FILE_SYMLINK => 'symlink', ); static $changes = array( DifferentialChangeType::TYPE_ADD => 'added', DifferentialChangeType::TYPE_CHANGE => 'changed', DifferentialChangeType::TYPE_DELETE => 'deleted', DifferentialChangeType::TYPE_MOVE_HERE => 'moved from', DifferentialChangeType::TYPE_COPY_HERE => 'copied from', DifferentialChangeType::TYPE_MOVE_AWAY => 'moved to', DifferentialChangeType::TYPE_COPY_AWAY => 'copied to', DifferentialChangeType::TYPE_MULTICOPY => 'deleted after being copied to', ); $change = $changeset->getChangeType(); $file = $changeset->getFileType(); $message = null; if ($change == DifferentialChangeType::TYPE_CHANGE && $file == DifferentialChangeType::FILE_TEXT) { if ($force) { // We have to force something to render because there were no changes // of other kinds. $message = "This {$files[$file]} was not modified."; } else { // Default case of changes to a text file, no metadata. return null; } } else { $verb = idx($changes, $change, 'changed'); switch ($change) { default: $message = "This {$files[$file]} was {$verb}."; break; case DifferentialChangeType::TYPE_MOVE_HERE: case DifferentialChangeType::TYPE_COPY_HERE: $message = "This {$files[$file]} was {$verb} ". "{$changeset->getOldFile()}."; break; case DifferentialChangeType::TYPE_MOVE_AWAY: case DifferentialChangeType::TYPE_COPY_AWAY: case DifferentialChangeType::TYPE_MULTICOPY: $paths = $changeset->getAwayPaths(); if (count($paths) > 1) { $message = "This {$files[$file]} was {$verb}: ". "".implode(', ', $paths)."."; } else { $message = "This {$files[$file]} was {$verb} ". "".reset($paths)."."; } break; case DifferentialChangeType::TYPE_CHANGE: $message = "This is ".idx($articles, $file, 'a')." {$files[$file]}."; break; } } return '
'. $message. '
'; } public function renderForEmail() { $ret = ''; $min = min(count($this->old), count($this->new)); for ($i = 0; $i < $min; $i++) { $o = $this->old[$i]; $n = $this->new[$i]; if (!isset($this->visible[$i])) { continue; } if ($o['line'] && $n['line']) { // It is quite possible there are better ways to achieve this. For // example, "white-space: pre;" can do a better job, WERE IT NOT for // broken email clients like OWA which use newlines to do weird // wrapping. So dont give them newlines. if (isset($this->intra[$i])) { $ret .= sprintf( "- %s
", str_replace(" ", " ", phutil_escape_html($o['text'])) ); $ret .= sprintf( "+ %s
", str_replace(" ", " ", phutil_escape_html($n['text'])) ); } else { $ret .= sprintf("  %s
", str_replace(" ", " ", phutil_escape_html($n['text'])) ); } } else if ($o['line'] && !$n['line']) { $ret .= sprintf( "- %s
", str_replace(" ", " ", phutil_escape_html($o['text'])) ); } else { $ret .= sprintf( "+ %s
", str_replace(" ", " ", phutil_escape_html($n['text'])) ); } } return $ret; } /** * 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); } } diff --git a/src/applications/differential/storage/changeset/DifferentialChangeset.php b/src/applications/differential/storage/changeset/DifferentialChangeset.php index 758e311c8f..b8f398ab15 100644 --- a/src/applications/differential/storage/changeset/DifferentialChangeset.php +++ b/src/applications/differential/storage/changeset/DifferentialChangeset.php @@ -1,187 +1,198 @@ array( 'metadata' => self::SERIALIZATION_JSON, 'oldProperties' => self::SERIALIZATION_JSON, 'newProperties' => self::SERIALIZATION_JSON, 'awayPaths' => self::SERIALIZATION_JSON, )) + parent::getConfiguration(); } public function getAffectedLineCount() { return $this->getAddLines() + $this->getDelLines(); } public function getFileType() { return $this->fileType; } public function getChangeType() { return $this->changeType; } public function attachHunks(array $hunks) { $this->hunks = $hunks; return $this; } public function getHunks() { if ($this->hunks === null) { throw new Exception("Must load and attach hunks first!"); } return $this->hunks; } public function getDisplayFilename() { $name = $this->getFilename(); if ($this->getFileType() == DifferentialChangeType::FILE_DIRECTORY) { $name .= '/'; } return $name; } public function addUnsavedHunk(DifferentialHunk $hunk) { if ($this->hunks === null) { $this->hunks = array(); } $this->hunks[] = $hunk; $this->unsavedHunks[] = $hunk; return $this; } public function loadHunks() { if (!$this->getID()) { return array(); } return id(new DifferentialHunk())->loadAllWhere( 'changesetID = %d', $this->getID()); } public function save() { // TODO: Sort out transactions // $this->openTransaction(); $ret = parent::save(); foreach ($this->unsavedHunks as $hunk) { $hunk->setChangesetID($this->getID()); $hunk->save(); } // $this->saveTransaction(); return $ret; } public function delete() { // $this->openTransaction(); foreach ($this->loadHunks() as $hunk) { $hunk->delete(); } $this->_hunks = array(); $ret = parent::delete(); // $this->saveTransaction(); return $ret; } public function getSortKey() { $sort_key = $this->getFilename(); // Sort files with ".h" in them first, so headers (.h, .hpp) come before // implementations (.c, .cpp, .cs). $sort_key = str_replace('.h', '.!h', $sort_key); return $sort_key; } public function makeNewFile() { $file = array(); foreach ($this->getHunks() as $hunk) { $file[] = $hunk->makeNewFile(); } return implode("\n", $file); } public function makeOldFile() { $file = array(); foreach ($this->getHunks() as $hunk) { $file[] = $hunk->makeOldFile(); } return implode("\n", $file); } public function getAnchorName() { return substr(md5($this->getFilename()), 0, 8); } public function getAbsoluteRepositoryPath( DifferentialDiff $diff, PhabricatorRepository $repository) { $base = '/'; if ($diff->getSourceControlPath()) { $base = id(new PhutilURI($diff->getSourceControlPath()))->getPath(); } $path = $this->getFileName(); $path = rtrim($base, '/').'/'.ltrim($path, '/'); $vcs = $repository->getVersionControlSystem(); if ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_SVN) { $prefix = $repository->getDetail('remote-uri'); $prefix = id(new PhutilURI($prefix))->getPath(); if (!strncmp($path, $prefix, strlen($prefix))) { $path = substr($path, strlen($prefix)); } $path = '/'.ltrim($path, '/'); } return $path; } /** * Retreive the configured wordwrap width for this changeset. */ public function getWordWrapWidth() { $config = PhabricatorEnv::getEnvConfig('differential.wordwrap'); foreach ($config as $regexp => $width) { if (preg_match($regexp, $this->getFileName())) { return $width; } } return 80; } + public function getWhitespaceMatters() { + $config = PhabricatorEnv::getEnvConfig('differential.whitespace-matters'); + foreach ($config as $regexp) { + if (preg_match($regexp, $this->getFileName())) { + return true; + } + } + + return false; + } + } diff --git a/src/applications/differential/view/revisionupdatehistory/DifferentialRevisionUpdateHistoryView.php b/src/applications/differential/view/revisionupdatehistory/DifferentialRevisionUpdateHistoryView.php index df14dfa595..c2711f588a 100644 --- a/src/applications/differential/view/revisionupdatehistory/DifferentialRevisionUpdateHistoryView.php +++ b/src/applications/differential/view/revisionupdatehistory/DifferentialRevisionUpdateHistoryView.php @@ -1,311 +1,311 @@ diffs = $diffs; return $this; } public function setSelectedVersusDiffID($id) { $this->selectedVersusDiffID = $id; return $this; } public function setSelectedDiffID($id) { $this->selectedDiffID = $id; return $this; } public function setSelectedWhitespace($whitespace) { $this->selectedWhitespace = $whitespace; return $this; } public function render() { require_celerity_resource('differential-core-view-css'); require_celerity_resource('differential-revision-history-css'); $data = array( array( 'name' => 'Base', 'id' => null, 'desc' => 'Base', 'age' => null, 'obj' => null, ), ); $seq = 0; foreach ($this->diffs as $diff) { $data[] = array( 'name' => 'Diff '.(++$seq), 'id' => $diff->getID(), 'desc' => $diff->getDescription(), 'age' => $diff->getDateCreated(), 'obj' => $diff, ); } $max_id = $diff->getID(); $idx = 0; $rows = array(); $disable = false; $radios = array(); $last_base = null; foreach ($data as $row) { $name = phutil_escape_html($row['name']); $id = phutil_escape_html($row['id']); $old_class = null; $new_class = null; if ($id) { $new_checked = ($this->selectedDiffID == $id); $new = javelin_render_tag( 'input', array( 'type' => 'radio', 'name' => 'id', 'value' => $id, 'checked' => $new_checked ? 'checked' : null, 'sigil' => 'differential-new-radio', )); if ($new_checked) { $new_class = " revhistory-new-now"; $disable = true; } } else { $new = null; } if ($max_id != $id) { $uniq = celerity_generate_unique_node_id(); $old_checked = ($this->selectedVersusDiffID == $id); $old = phutil_render_tag( 'input', array( 'type' => 'radio', 'name' => 'vs', 'value' => $id, 'id' => $uniq, 'checked' => $old_checked ? 'checked' : null, 'disabled' => $disable ? 'disabled' : null, )); $radios[] = $uniq; if ($old_checked) { $old_class = " revhistory-old-now"; } } else { $old = null; } $desc = $row['desc']; if ($row['age']) { $age = phabricator_format_timestamp($row['age']); } else { $age = null; } if (++$idx % 2) { $class = ' class="alt"'; } else { $class = null; } if ($row['obj']) { $lint = self::renderDiffLintStar($row['obj']); $unit = self::renderDiffUnitStar($row['obj']); } else { $lint = null; $unit = null; } $base = $this->renderBaseRevision($diff); if ($last_base !== null && $base !== $last_base) { // TODO: Render some kind of notice about rebases. } $last_base = $base; $rows[] = ''. ''.$name.''. ''.$id.''. ''.phutil_escape_html($base).''. ''.phutil_escape_html($desc).''. ''.$age.''. ''.$lint.''. ''.$unit.''. ''.$old.''. ''.$new.''. ''; } Javelin::initBehavior( 'differential-diff-radios', array( 'radios' => $radios, )); $options = array( - 'ignore-all' => 'Ignore All', + 'ignore-all' => 'Ignore Most', 'ignore-trailing' => 'Ignore Trailing', 'show-all' => 'Show All', ); $select = ''; return '
'. '

Revision Update History

'. '
'. ''. ''. ''. ''. ''. ''. ''. ''. ''. ''. implode("\n", $rows). ''. ''. ''. '
DiffIDBaseDescriptionCreatedLintUnit
'. ''. ''. '
'. '
'. '
'; } const STAR_NONE = 'none'; const STAR_OKAY = 'okay'; const STAR_WARN = 'warn'; const STAR_FAIL = 'fail'; const STAR_SKIP = 'skip'; public static function renderDiffLintStar(DifferentialDiff $diff) { static $map = array( DifferentialLintStatus::LINT_NONE => self::STAR_NONE, DifferentialLintStatus::LINT_OKAY => self::STAR_OKAY, DifferentialLintStatus::LINT_WARN => self::STAR_WARN, DifferentialLintStatus::LINT_FAIL => self::STAR_FAIL, DifferentialLintStatus::LINT_SKIP => self::STAR_SKIP, ); $star = idx($map, $diff->getLintStatus(), self::STAR_FAIL); return self::renderDiffStar($star); } public static function renderDiffUnitStar(DifferentialDiff $diff) { static $map = array( DifferentialUnitStatus::UNIT_NONE => self::STAR_NONE, DifferentialUnitStatus::UNIT_OKAY => self::STAR_OKAY, DifferentialUnitStatus::UNIT_WARN => self::STAR_WARN, DifferentialUnitStatus::UNIT_FAIL => self::STAR_FAIL, DifferentialUnitStatus::UNIT_SKIP => self::STAR_SKIP, DifferentialUnitStatus::UNIT_POSTPONED => self::STAR_SKIP, ); $star = idx($map, $diff->getUnitStatus(), self::STAR_FAIL); return self::renderDiffStar($star); } public static function getDiffLintMessage(DifferentialDiff $diff) { switch ($diff->getLintStatus()) { case DifferentialLintStatus::LINT_NONE: return 'No Linters Available'; case DifferentialLintStatus::LINT_OKAY: return 'Lint OK'; case DifferentialLintStatus::LINT_WARN: return 'Lint Warnings'; case DifferentialLintStatus::LINT_FAIL: return 'Lint Errors'; case DifferentialLintStatus::LINT_SKIP: return 'Lint Skipped'; } return '???'; } public static function getDiffUnitMessage(DifferentialDiff $diff) { switch ($diff->getUnitStatus()) { case DifferentialUnitStatus::UNIT_NONE: return 'No Unit Test Coverage'; case DifferentialUnitStatus::UNIT_OKAY: return 'Unit Tests OK'; case DifferentialUnitStatus::UNIT_WARN: return 'Unit Test Warnings'; case DifferentialUnitStatus::UNIT_FAIL: return 'Unit Test Errors'; case DifferentialUnitStatus::UNIT_SKIP: return 'Unit Tests Skipped'; case DifferentialUnitStatus::UNIT_POSTPONED: return 'Unit Tests Postponed'; } return '???'; } private static function renderDiffStar($star) { $class = 'diff-star-'.$star; return ''. "\xE2\x98\x85". ''; } private function renderBaseRevision(DifferentialDiff $diff) { switch ($diff->getSourceControlSystem()) { case 'git': return substr($diff->getSourceControlBaseRevision(), 0, 7); case 'svn': $base = $diff->getSourceControlBaseRevision(); $base = explode('@', $base); $base = end($base); return $base; default: return null; } } }