diff --git a/resources/celerity/map.php b/resources/celerity/map.php index ccae91f844..bc578201f4 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -1,2442 +1,2442 @@ array( 'conpherence.pkg.css' => '0e3cf785', 'conpherence.pkg.js' => '020aebcf', - 'core.pkg.css' => '1b80c45d', - 'core.pkg.js' => '1e667bcb', + 'core.pkg.css' => 'ba768cdb', + 'core.pkg.js' => '845355f4', 'dark-console.pkg.js' => '187792c2', - 'differential.pkg.css' => 'd71d4531', - 'differential.pkg.js' => '5be7941a', + 'differential.pkg.css' => '42a2334f', + 'differential.pkg.js' => '623b4801', 'diffusion.pkg.css' => '42c75c37', 'diffusion.pkg.js' => 'a98c0bf7', 'maniphest.pkg.css' => '35995d6d', 'maniphest.pkg.js' => 'c9308721', 'rsrc/audio/basic/alert.mp3' => '17889334', 'rsrc/audio/basic/bing.mp3' => 'a817a0c3', 'rsrc/audio/basic/pock.mp3' => '0fa843d0', 'rsrc/audio/basic/tap.mp3' => '02d16994', 'rsrc/audio/basic/ting.mp3' => 'a6b6540e', 'rsrc/css/aphront/aphront-bars.css' => '4a327b4a', 'rsrc/css/aphront/dark-console.css' => '7f06cda2', 'rsrc/css/aphront/dialog-view.css' => '6f4ea703', 'rsrc/css/aphront/list-filter-view.css' => 'feb64255', 'rsrc/css/aphront/multi-column.css' => 'fbc00ba3', 'rsrc/css/aphront/notification.css' => '30240bd2', 'rsrc/css/aphront/panel-view.css' => '46923d46', 'rsrc/css/aphront/phabricator-nav-view.css' => '423f92cc', 'rsrc/css/aphront/table-view.css' => '0bb61df1', 'rsrc/css/aphront/tokenizer.css' => '34e2a838', 'rsrc/css/aphront/tooltip.css' => 'e3f2412f', 'rsrc/css/aphront/typeahead-browse.css' => 'b7ed02d2', 'rsrc/css/aphront/typeahead.css' => '8779483d', 'rsrc/css/application/almanac/almanac.css' => '2e050f4f', 'rsrc/css/application/auth/auth.css' => 'c2f23d74', 'rsrc/css/application/base/main-menu-view.css' => 'bcec20f0', 'rsrc/css/application/base/notification-menu.css' => '4df1ee30', 'rsrc/css/application/base/phui-theme.css' => '35883b37', 'rsrc/css/application/base/standard-page-view.css' => 'a374f94c', 'rsrc/css/application/chatlog/chatlog.css' => 'abdc76ee', 'rsrc/css/application/conduit/conduit-api.css' => 'ce2cfc41', 'rsrc/css/application/config/config-options.css' => '16c920ae', 'rsrc/css/application/config/config-template.css' => '20babf50', 'rsrc/css/application/config/setup-issue.css' => '5eed85b2', 'rsrc/css/application/config/unhandled-exception.css' => '9ecfc00d', 'rsrc/css/application/conpherence/color.css' => 'b17746b0', 'rsrc/css/application/conpherence/durable-column.css' => '2d57072b', 'rsrc/css/application/conpherence/header-pane.css' => 'c9a3db8e', 'rsrc/css/application/conpherence/menu.css' => '67f4680d', 'rsrc/css/application/conpherence/message-pane.css' => 'd244db1e', 'rsrc/css/application/conpherence/notification.css' => '6a3d4e58', 'rsrc/css/application/conpherence/participant-pane.css' => '69e0058a', 'rsrc/css/application/conpherence/transaction.css' => '3a3f5e7e', 'rsrc/css/application/contentsource/content-source-view.css' => 'cdf0d579', 'rsrc/css/application/countdown/timer.css' => 'bff8012f', 'rsrc/css/application/daemon/bulk-job.css' => '73af99f5', 'rsrc/css/application/dashboard/dashboard.css' => '5a205b9d', 'rsrc/css/application/diff/diff-tree-view.css' => 'e2d3e222', 'rsrc/css/application/diff/inline-comment-summary.css' => '81eb368d', 'rsrc/css/application/differential/add-comment.css' => '7e5900d9', - 'rsrc/css/application/differential/changeset-view.css' => 'a5cc67cf', + 'rsrc/css/application/differential/changeset-view.css' => '60c3d405', 'rsrc/css/application/differential/core.css' => '7300a73e', - 'rsrc/css/application/differential/phui-inline-comment.css' => '48acce5b', + 'rsrc/css/application/differential/phui-inline-comment.css' => 'd5749acc', 'rsrc/css/application/differential/revision-comment.css' => '7dbc8d1d', 'rsrc/css/application/differential/revision-history.css' => '8aa3eac5', 'rsrc/css/application/differential/revision-list.css' => '93d2df7d', 'rsrc/css/application/differential/table-of-contents.css' => 'bba788b9', 'rsrc/css/application/diffusion/diffusion-icons.css' => '23b31a1b', 'rsrc/css/application/diffusion/diffusion-readme.css' => 'b68a76e4', 'rsrc/css/application/diffusion/diffusion-repository.css' => 'b89e8c6c', 'rsrc/css/application/diffusion/diffusion.css' => 'b54c77b0', 'rsrc/css/application/feed/feed.css' => 'd8b6e3f8', 'rsrc/css/application/files/global-drag-and-drop.css' => '1d2713a4', 'rsrc/css/application/flag/flag.css' => '2b77be8d', 'rsrc/css/application/harbormaster/harbormaster.css' => '8dfe16b2', 'rsrc/css/application/herald/herald-test.css' => 'e004176f', 'rsrc/css/application/herald/herald.css' => '648d39e2', 'rsrc/css/application/maniphest/report.css' => '3d53188b', 'rsrc/css/application/maniphest/task-edit.css' => '272daa84', 'rsrc/css/application/maniphest/task-summary.css' => '61d1667e', 'rsrc/css/application/objectselector/object-selector.css' => 'ee77366f', 'rsrc/css/application/owners/owners-path-editor.css' => 'fa7c13ef', 'rsrc/css/application/paste/paste.css' => 'b37bcd38', 'rsrc/css/application/people/people-picture-menu-item.css' => 'fe8e07cf', 'rsrc/css/application/people/people-profile.css' => '2ea2daa1', 'rsrc/css/application/phame/phame.css' => 'bb442327', 'rsrc/css/application/pholio/pholio-edit.css' => '4df55b3b', 'rsrc/css/application/pholio/pholio-inline-comments.css' => '722b48c2', 'rsrc/css/application/pholio/pholio.css' => '88ef5ef1', 'rsrc/css/application/phortune/phortune-credit-card-form.css' => '3b9868a8', 'rsrc/css/application/phortune/phortune-invoice.css' => '4436b241', 'rsrc/css/application/phortune/phortune.css' => '508a1a5e', 'rsrc/css/application/phrequent/phrequent.css' => 'bd79cc67', 'rsrc/css/application/phriction/phriction-document-css.css' => '03380da0', 'rsrc/css/application/policy/policy-edit.css' => '8794e2ed', 'rsrc/css/application/policy/policy-transaction-detail.css' => 'c02b8384', 'rsrc/css/application/policy/policy.css' => 'ceb56a08', 'rsrc/css/application/ponder/ponder-view.css' => '05a09d0a', 'rsrc/css/application/project/project-card-view.css' => '4e7371cd', 'rsrc/css/application/project/project-triggers.css' => 'cd9c8bb9', 'rsrc/css/application/project/project-view.css' => '567858b3', 'rsrc/css/application/releeph/releeph-core.css' => 'f81ff2db', 'rsrc/css/application/releeph/releeph-preview-branch.css' => '22db5c07', 'rsrc/css/application/releeph/releeph-request-differential-create-dialog.css' => '0ac1ea31', 'rsrc/css/application/releeph/releeph-request-typeahead.css' => 'bce37359', 'rsrc/css/application/search/application-search-view.css' => '0f7c06d8', 'rsrc/css/application/search/search-results.css' => '9ea70ace', 'rsrc/css/application/slowvote/slowvote.css' => '1694baed', 'rsrc/css/application/tokens/tokens.css' => 'ce5a50bd', 'rsrc/css/application/uiexample/example.css' => 'b4795059', 'rsrc/css/core/core.css' => '1b29ed61', 'rsrc/css/core/remarkup.css' => 'c286eaef', - 'rsrc/css/core/syntax.css' => '220b85f9', - 'rsrc/css/core/z-index.css' => '612e9522', + 'rsrc/css/core/syntax.css' => '548567f6', + 'rsrc/css/core/z-index.css' => 'ac3bfcd4', 'rsrc/css/diviner/diviner-shared.css' => '4bd263b0', 'rsrc/css/font/font-awesome.css' => '3883938a', 'rsrc/css/font/font-lato.css' => '23631304', 'rsrc/css/font/phui-font-icon-base.css' => '303c9b87', 'rsrc/css/layout/phabricator-source-code-view.css' => '03d7ac28', 'rsrc/css/phui/button/phui-button-bar.css' => 'a4aa75c4', 'rsrc/css/phui/button/phui-button-simple.css' => '1ff278aa', 'rsrc/css/phui/button/phui-button.css' => 'ea704902', 'rsrc/css/phui/calendar/phui-calendar-day.css' => '9597d706', 'rsrc/css/phui/calendar/phui-calendar-list.css' => 'ccd7e4e2', 'rsrc/css/phui/calendar/phui-calendar-month.css' => 'cb758c42', 'rsrc/css/phui/calendar/phui-calendar.css' => 'f11073aa', 'rsrc/css/phui/object-item/phui-oi-big-ui.css' => 'fa74cc35', 'rsrc/css/phui/object-item/phui-oi-color.css' => 'b517bfa0', 'rsrc/css/phui/object-item/phui-oi-drag-ui.css' => 'da15d3dc', 'rsrc/css/phui/object-item/phui-oi-flush-ui.css' => '490e2e2e', 'rsrc/css/phui/object-item/phui-oi-list-view.css' => 'd7723ecc', 'rsrc/css/phui/object-item/phui-oi-simple-ui.css' => '6a30fa46', 'rsrc/css/phui/phui-action-list.css' => '1b0085b2', 'rsrc/css/phui/phui-action-panel.css' => '6c386cbf', 'rsrc/css/phui/phui-badge.css' => '666e25ad', 'rsrc/css/phui/phui-basic-nav-view.css' => '56ebd66d', 'rsrc/css/phui/phui-big-info-view.css' => '362ad37b', 'rsrc/css/phui/phui-box.css' => '5ed3b8cb', 'rsrc/css/phui/phui-bulk-editor.css' => '374d5e30', 'rsrc/css/phui/phui-chart.css' => '14df9ae3', 'rsrc/css/phui/phui-cms.css' => '8c05c41e', 'rsrc/css/phui/phui-comment-form.css' => '68a2d99a', 'rsrc/css/phui/phui-comment-panel.css' => 'ec4e31c0', 'rsrc/css/phui/phui-crumbs-view.css' => '614f43cf', 'rsrc/css/phui/phui-curtain-object-ref-view.css' => '12404744', 'rsrc/css/phui/phui-curtain-view.css' => '68c5efb6', 'rsrc/css/phui/phui-document-pro.css' => 'b9613a10', 'rsrc/css/phui/phui-document-summary.css' => 'b068eed1', 'rsrc/css/phui/phui-document.css' => '52b748a5', 'rsrc/css/phui/phui-feed-story.css' => 'a0c05029', 'rsrc/css/phui/phui-fontkit.css' => '1ec937e5', 'rsrc/css/phui/phui-form-view.css' => '01b796c0', 'rsrc/css/phui/phui-form.css' => '1f177cb7', 'rsrc/css/phui/phui-formation-view.css' => 'd2dec8ed', 'rsrc/css/phui/phui-head-thing.css' => 'd7f293df', 'rsrc/css/phui/phui-header-view.css' => '36c86a58', 'rsrc/css/phui/phui-hovercard.css' => '6ca90fa0', 'rsrc/css/phui/phui-icon-set-selector.css' => '7aa5f3ec', 'rsrc/css/phui/phui-icon.css' => '4cbc684a', 'rsrc/css/phui/phui-image-mask.css' => '62c7f4d2', 'rsrc/css/phui/phui-info-view.css' => 'a10a909b', 'rsrc/css/phui/phui-invisible-character-view.css' => 'c694c4a4', 'rsrc/css/phui/phui-left-right.css' => '68513c34', 'rsrc/css/phui/phui-lightbox.css' => '4ebf22da', 'rsrc/css/phui/phui-list.css' => '2f253c22', 'rsrc/css/phui/phui-object-box.css' => 'b8d7eea0', 'rsrc/css/phui/phui-pager.css' => 'd022c7ad', 'rsrc/css/phui/phui-pinboard-view.css' => '1f08f5d8', 'rsrc/css/phui/phui-policy-section-view.css' => '139fdc64', - 'rsrc/css/phui/phui-property-list-view.css' => '9c477af1', + 'rsrc/css/phui/phui-property-list-view.css' => '5adf7078', 'rsrc/css/phui/phui-remarkup-preview.css' => '91767007', 'rsrc/css/phui/phui-segment-bar-view.css' => '5166b370', 'rsrc/css/phui/phui-spacing.css' => 'b05cadc3', 'rsrc/css/phui/phui-status.css' => 'e5ff8be0', 'rsrc/css/phui/phui-tag-view.css' => '8519160a', 'rsrc/css/phui/phui-timeline-view.css' => '2d32d7a9', 'rsrc/css/phui/phui-two-column-view.css' => 'f96d319f', 'rsrc/css/phui/workboards/phui-workboard-color.css' => 'e86de308', 'rsrc/css/phui/workboards/phui-workboard.css' => '74fc9d98', 'rsrc/css/phui/workboards/phui-workcard.css' => '913441b6', 'rsrc/css/phui/workboards/phui-workpanel.css' => '3ae89b20', 'rsrc/css/sprite-login.css' => '18b368a6', 'rsrc/css/sprite-tokens.css' => 'f1896dc5', 'rsrc/css/syntax/syntax-default.css' => '055fc231', 'rsrc/externals/d3/d3.min.js' => '9d068042', 'rsrc/externals/font/fontawesome/fontawesome-webfont.eot' => '23f8c698', 'rsrc/externals/font/fontawesome/fontawesome-webfont.ttf' => '70983df0', 'rsrc/externals/font/fontawesome/fontawesome-webfont.woff' => 'cd02f93b', 'rsrc/externals/font/fontawesome/fontawesome-webfont.woff2' => '351fd46a', 'rsrc/externals/font/lato/lato-bold.eot' => '7367aa5e', 'rsrc/externals/font/lato/lato-bold.svg' => '681aa4f5', 'rsrc/externals/font/lato/lato-bold.ttf' => '66d3c296', 'rsrc/externals/font/lato/lato-bold.woff' => '89d9fba7', 'rsrc/externals/font/lato/lato-bold.woff2' => '389fcdb1', 'rsrc/externals/font/lato/lato-bolditalic.eot' => '03eeb4da', 'rsrc/externals/font/lato/lato-bolditalic.svg' => 'f56fa11c', 'rsrc/externals/font/lato/lato-bolditalic.ttf' => '9c3aec21', 'rsrc/externals/font/lato/lato-bolditalic.woff' => 'bfbd0616', 'rsrc/externals/font/lato/lato-bolditalic.woff2' => 'bc7d1274', 'rsrc/externals/font/lato/lato-italic.eot' => '7db5b247', 'rsrc/externals/font/lato/lato-italic.svg' => 'b1ae496f', 'rsrc/externals/font/lato/lato-italic.ttf' => '43eed813', 'rsrc/externals/font/lato/lato-italic.woff' => 'c28975e1', 'rsrc/externals/font/lato/lato-italic.woff2' => 'fffc0d8c', 'rsrc/externals/font/lato/lato-regular.eot' => '06e0c291', 'rsrc/externals/font/lato/lato-regular.svg' => '3ad95f53', 'rsrc/externals/font/lato/lato-regular.ttf' => 'e2e9c398', 'rsrc/externals/font/lato/lato-regular.woff' => '0b13d332', 'rsrc/externals/font/lato/lato-regular.woff2' => '8f846797', 'rsrc/externals/javelin/core/Event.js' => 'c03f2fb4', 'rsrc/externals/javelin/core/Stratcom.js' => '0889b835', 'rsrc/externals/javelin/core/__tests__/event-stop-and-kill.js' => '048472d2', 'rsrc/externals/javelin/core/__tests__/install.js' => '14a7e671', 'rsrc/externals/javelin/core/__tests__/stratcom.js' => 'a28464bb', 'rsrc/externals/javelin/core/__tests__/util.js' => 'e29a4354', 'rsrc/externals/javelin/core/init.js' => '98e6504a', 'rsrc/externals/javelin/core/init_node.js' => '16961339', 'rsrc/externals/javelin/core/install.js' => '5902260c', 'rsrc/externals/javelin/core/util.js' => 'edb4d8c9', 'rsrc/externals/javelin/docs/Base.js' => '5a401d7d', 'rsrc/externals/javelin/docs/onload.js' => 'ee58fb62', 'rsrc/externals/javelin/ext/fx/Color.js' => '78f811c9', 'rsrc/externals/javelin/ext/fx/FX.js' => '34450586', 'rsrc/externals/javelin/ext/reactor/core/DynVal.js' => '202a2e85', 'rsrc/externals/javelin/ext/reactor/core/Reactor.js' => '1c850a26', 'rsrc/externals/javelin/ext/reactor/core/ReactorNode.js' => '72960bc1', 'rsrc/externals/javelin/ext/reactor/core/ReactorNodeCalmer.js' => '225bbb98', 'rsrc/externals/javelin/ext/reactor/dom/RDOM.js' => '6cfa0008', 'rsrc/externals/javelin/ext/view/HTMLView.js' => 'f8c4e135', 'rsrc/externals/javelin/ext/view/View.js' => '289bf236', 'rsrc/externals/javelin/ext/view/ViewInterpreter.js' => '876506b6', 'rsrc/externals/javelin/ext/view/ViewPlaceholder.js' => 'a9942052', 'rsrc/externals/javelin/ext/view/ViewRenderer.js' => '9aae2b66', 'rsrc/externals/javelin/ext/view/ViewVisitor.js' => '308f9fe4', 'rsrc/externals/javelin/ext/view/__tests__/HTMLView.js' => '6e50a13f', 'rsrc/externals/javelin/ext/view/__tests__/View.js' => 'd284be5d', 'rsrc/externals/javelin/ext/view/__tests__/ViewInterpreter.js' => 'a9f35511', 'rsrc/externals/javelin/ext/view/__tests__/ViewRenderer.js' => '3a1b81f6', 'rsrc/externals/javelin/lib/Cookie.js' => '05d290ef', 'rsrc/externals/javelin/lib/DOM.js' => '94681e22', 'rsrc/externals/javelin/lib/History.js' => '030b4f7a', 'rsrc/externals/javelin/lib/JSON.js' => '541f81c3', 'rsrc/externals/javelin/lib/Leader.js' => '0d2490ce', 'rsrc/externals/javelin/lib/Mask.js' => '7c4d8998', 'rsrc/externals/javelin/lib/Quicksand.js' => 'd3799cb4', 'rsrc/externals/javelin/lib/Request.js' => '84e6891f', 'rsrc/externals/javelin/lib/Resource.js' => '740956e1', 'rsrc/externals/javelin/lib/Routable.js' => '6a18c42e', 'rsrc/externals/javelin/lib/Router.js' => '32755edb', 'rsrc/externals/javelin/lib/Scrollbar.js' => 'a43ae2ae', 'rsrc/externals/javelin/lib/Sound.js' => 'd4cc2d2a', 'rsrc/externals/javelin/lib/URI.js' => '2e255291', 'rsrc/externals/javelin/lib/Vector.js' => 'e9c80beb', 'rsrc/externals/javelin/lib/WebSocket.js' => 'fdc13e4e', 'rsrc/externals/javelin/lib/Workflow.js' => '945ff654', 'rsrc/externals/javelin/lib/__tests__/Cookie.js' => 'ca686f71', 'rsrc/externals/javelin/lib/__tests__/DOM.js' => '4566e249', 'rsrc/externals/javelin/lib/__tests__/JSON.js' => '710377ae', 'rsrc/externals/javelin/lib/__tests__/URI.js' => '6fff0c2b', 'rsrc/externals/javelin/lib/__tests__/behavior.js' => '8426ebeb', 'rsrc/externals/javelin/lib/behavior.js' => '1b6acc2a', 'rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js' => '89a1ae3a', 'rsrc/externals/javelin/lib/control/typeahead/Typeahead.js' => 'a4356cde', 'rsrc/externals/javelin/lib/control/typeahead/normalizer/TypeaheadNormalizer.js' => 'a241536a', 'rsrc/externals/javelin/lib/control/typeahead/source/TypeaheadCompositeSource.js' => '22ee68a5', 'rsrc/externals/javelin/lib/control/typeahead/source/TypeaheadOnDemandSource.js' => '23387297', 'rsrc/externals/javelin/lib/control/typeahead/source/TypeaheadPreloadedSource.js' => '5a79f6c3', 'rsrc/externals/javelin/lib/control/typeahead/source/TypeaheadSource.js' => '8badee71', 'rsrc/externals/javelin/lib/control/typeahead/source/TypeaheadStaticSource.js' => '80bff3af', 'rsrc/favicons/favicon-16x16.png' => '4c51a03a', 'rsrc/favicons/mask-icon.svg' => 'db699fe1', 'rsrc/image/BFCFDA.png' => '74b5c88b', 'rsrc/image/actions/edit.png' => 'fd987dff', 'rsrc/image/avatar.png' => '0d17c6c4', 'rsrc/image/checker_dark.png' => '7fc8fa7b', 'rsrc/image/checker_light.png' => '3157a202', 'rsrc/image/checker_lighter.png' => 'c45928c1', 'rsrc/image/chevron-in.png' => '1aa2f88f', 'rsrc/image/chevron-out.png' => 'c815e272', 'rsrc/image/controls/checkbox-checked.png' => '1770d7a0', 'rsrc/image/controls/checkbox-unchecked.png' => 'e1deba0a', 'rsrc/image/d5d8e1.png' => '6764616e', 'rsrc/image/darkload.gif' => '5bd41a89', 'rsrc/image/divot.png' => '0fbe2453', 'rsrc/image/examples/hero.png' => '5d8c4b21', 'rsrc/image/grippy_texture.png' => 'a7d222b5', 'rsrc/image/icon/fatcow/arrow_branch.png' => '98149d9f', 'rsrc/image/icon/fatcow/arrow_merge.png' => 'e142f4f8', 'rsrc/image/icon/fatcow/calendar_edit.png' => '5ff44a08', 'rsrc/image/icon/fatcow/document_black.png' => 'd3515fa5', 'rsrc/image/icon/fatcow/flag_blue.png' => '54db2e5c', 'rsrc/image/icon/fatcow/flag_finish.png' => '2953a51b', 'rsrc/image/icon/fatcow/flag_ghost.png' => '7d9ada92', 'rsrc/image/icon/fatcow/flag_green.png' => '010f7161', 'rsrc/image/icon/fatcow/flag_orange.png' => '6c384ca5', 'rsrc/image/icon/fatcow/flag_pink.png' => '11ac6b12', 'rsrc/image/icon/fatcow/flag_purple.png' => 'c4f423a4', 'rsrc/image/icon/fatcow/flag_red.png' => '9e6d8817', 'rsrc/image/icon/fatcow/flag_yellow.png' => '906733f4', 'rsrc/image/icon/fatcow/key_question.png' => 'c10c26db', 'rsrc/image/icon/fatcow/link.png' => '8edbf327', 'rsrc/image/icon/fatcow/page_white_edit.png' => '17ef5625', 'rsrc/image/icon/fatcow/page_white_put.png' => '82430c91', 'rsrc/image/icon/fatcow/source/conduit.png' => '5b55130c', 'rsrc/image/icon/fatcow/source/email.png' => '8a32b77f', 'rsrc/image/icon/fatcow/source/fax.png' => '8bc2a49b', 'rsrc/image/icon/fatcow/source/mobile.png' => '0a918412', 'rsrc/image/icon/fatcow/source/tablet.png' => 'fc50b050', 'rsrc/image/icon/fatcow/source/web.png' => '70433af3', 'rsrc/image/icon/subscribe.png' => '07ef454e', 'rsrc/image/icon/tango/attachment.png' => 'bac9032d', 'rsrc/image/icon/tango/edit.png' => 'e6296206', 'rsrc/image/icon/tango/go-down.png' => '0b903712', 'rsrc/image/icon/tango/log.png' => '86b6a6f4', 'rsrc/image/icon/tango/upload.png' => '3fe6b92d', 'rsrc/image/icon/unsubscribe.png' => 'db04378a', 'rsrc/image/lightblue-header.png' => 'e6d483c6', 'rsrc/image/logo/light-eye.png' => '72337472', 'rsrc/image/main_texture.png' => '894d03c4', 'rsrc/image/menu_texture.png' => '896c9ade', 'rsrc/image/people/harding.png' => '95b2db63', 'rsrc/image/people/jefferson.png' => 'e883a3a2', 'rsrc/image/people/lincoln.png' => 'be2c07c5', 'rsrc/image/people/mckinley.png' => '6af510a0', 'rsrc/image/people/taft.png' => 'b15ab07e', 'rsrc/image/people/user0.png' => '4bc64b40', 'rsrc/image/people/user1.png' => '8063f445', 'rsrc/image/people/user2.png' => 'd28246c0', 'rsrc/image/people/user3.png' => 'fb1ac12d', 'rsrc/image/people/user4.png' => 'fe4fac8f', 'rsrc/image/people/user5.png' => '3d07065c', 'rsrc/image/people/user6.png' => 'e4bd47c8', 'rsrc/image/people/user7.png' => '71d8fe8b', 'rsrc/image/people/user8.png' => '85f86bf7', 'rsrc/image/people/user9.png' => '523db8aa', 'rsrc/image/people/washington.png' => '86159e68', 'rsrc/image/phrequent_active.png' => 'de66dc50', 'rsrc/image/phrequent_inactive.png' => '79c61baf', 'rsrc/image/resize.png' => '9cc83373', 'rsrc/image/sprite-login-X2.png' => '604545f6', 'rsrc/image/sprite-login.png' => '7a001a9a', 'rsrc/image/sprite-tokens-X2.png' => '21621dd9', 'rsrc/image/sprite-tokens.png' => 'bede2580', 'rsrc/image/texture/card-gradient.png' => 'e6892cb4', 'rsrc/image/texture/dark-menu-hover.png' => '390a4fa1', 'rsrc/image/texture/dark-menu.png' => '542f699c', 'rsrc/image/texture/grip.png' => 'bc80753a', 'rsrc/image/texture/panel-header-gradient.png' => '65004dbf', 'rsrc/image/texture/phlnx-bg.png' => '6c9cd31d', 'rsrc/image/texture/pholio-background.gif' => '84910bfc', 'rsrc/image/texture/table_header.png' => '7652d1ad', 'rsrc/image/texture/table_header_hover.png' => '12ea5236', 'rsrc/image/texture/table_header_tall.png' => '5cc420c4', 'rsrc/js/application/aphlict/Aphlict.js' => '022516b4', 'rsrc/js/application/aphlict/behavior-aphlict-dropdown.js' => 'e9a2940f', 'rsrc/js/application/aphlict/behavior-aphlict-listen.js' => '4e61fa88', 'rsrc/js/application/aphlict/behavior-aphlict-status.js' => 'c3703a16', 'rsrc/js/application/aphlict/behavior-desktop-notifications-control.js' => '070679fe', 'rsrc/js/application/calendar/behavior-day-view.js' => '727a5a61', 'rsrc/js/application/calendar/behavior-event-all-day.js' => '0b1bc990', 'rsrc/js/application/calendar/behavior-month-view.js' => '158c64e0', 'rsrc/js/application/config/behavior-reorder-fields.js' => '2539f834', 'rsrc/js/application/conpherence/ConpherenceThreadManager.js' => 'aec8e38c', 'rsrc/js/application/conpherence/behavior-conpherence-search.js' => '91befbcc', 'rsrc/js/application/conpherence/behavior-durable-column.js' => 'fa6f30b2', 'rsrc/js/application/conpherence/behavior-menu.js' => '8c2ed2bf', 'rsrc/js/application/conpherence/behavior-participant-pane.js' => '43ba89a2', 'rsrc/js/application/conpherence/behavior-pontificate.js' => '4ae58b5a', 'rsrc/js/application/conpherence/behavior-quicksand-blacklist.js' => '5a6f6a06', 'rsrc/js/application/conpherence/behavior-toggle-widget.js' => '8f959ad0', 'rsrc/js/application/countdown/timer.js' => '6a162524', 'rsrc/js/application/daemon/behavior-bulk-job-reload.js' => '3829a3cf', 'rsrc/js/application/dashboard/behavior-dashboard-async-panel.js' => '9c01e364', 'rsrc/js/application/dashboard/behavior-dashboard-move-panels.js' => 'a2ab19be', 'rsrc/js/application/dashboard/behavior-dashboard-query-panel-select.js' => '1e413dc9', 'rsrc/js/application/dashboard/behavior-dashboard-tab-panel.js' => '0116d3e8', - 'rsrc/js/application/diff/DiffChangeset.js' => '700bf848', - 'rsrc/js/application/diff/DiffChangesetList.js' => '6e668c5b', - 'rsrc/js/application/diff/DiffInline.js' => '9a3963e0', + 'rsrc/js/application/diff/DiffChangeset.js' => '68d963eb', + 'rsrc/js/application/diff/DiffChangesetList.js' => 'ac403c32', + 'rsrc/js/application/diff/DiffInline.js' => 'b00168c1', 'rsrc/js/application/diff/DiffPathView.js' => '8207abf9', 'rsrc/js/application/diff/DiffTreeView.js' => '5d83623b', 'rsrc/js/application/differential/behavior-diff-radios.js' => '925fe8cd', 'rsrc/js/application/differential/behavior-populate.js' => 'b86ef6c2', 'rsrc/js/application/diffusion/DiffusionLocateFileSource.js' => '94243d89', 'rsrc/js/application/diffusion/behavior-audit-preview.js' => 'b7b73831', 'rsrc/js/application/diffusion/behavior-commit-branches.js' => '4b671572', 'rsrc/js/application/diffusion/behavior-commit-graph.js' => 'ef836bf2', 'rsrc/js/application/diffusion/behavior-locate-file.js' => '87428eb2', 'rsrc/js/application/diffusion/behavior-pull-lastmodified.js' => 'c715c123', 'rsrc/js/application/doorkeeper/behavior-doorkeeper-tag.js' => '6a85bc5a', 'rsrc/js/application/drydock/drydock-live-operation-status.js' => '47a0728b', 'rsrc/js/application/fact/Chart.js' => '52e3ff03', 'rsrc/js/application/fact/ChartCurtainView.js' => '86954222', 'rsrc/js/application/fact/ChartFunctionLabel.js' => '81de1dab', 'rsrc/js/application/files/behavior-document-engine.js' => '243d6c22', 'rsrc/js/application/files/behavior-icon-composer.js' => '38a6cedb', 'rsrc/js/application/files/behavior-launch-icon-composer.js' => 'a17b84f1', 'rsrc/js/application/harbormaster/behavior-harbormaster-log.js' => 'b347a301', 'rsrc/js/application/herald/HeraldRuleEditor.js' => '2633bef7', 'rsrc/js/application/herald/PathTypeahead.js' => 'ad486db3', 'rsrc/js/application/herald/herald-rule-editor.js' => '0922e81d', 'rsrc/js/application/maniphest/behavior-batch-selector.js' => '139ef688', 'rsrc/js/application/maniphest/behavior-line-chart.js' => 'ad258e28', 'rsrc/js/application/maniphest/behavior-list-edit.js' => 'c687e867', 'rsrc/js/application/owners/OwnersPathEditor.js' => '2a8b62d9', 'rsrc/js/application/owners/owners-path-editor.js' => 'ff688a7a', 'rsrc/js/application/passphrase/passphrase-credential-control.js' => '48fe33d0', 'rsrc/js/application/pholio/behavior-pholio-mock-edit.js' => '3eed1f2b', 'rsrc/js/application/pholio/behavior-pholio-mock-view.js' => '5aa1544e', 'rsrc/js/application/phortune/behavior-stripe-payment-form.js' => '02cb4398', 'rsrc/js/application/phortune/behavior-test-payment-form.js' => '4a7fb02b', 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', 'rsrc/js/application/projects/WorkboardBoard.js' => 'b46d88c5', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '84f82dad', 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', 'rsrc/js/application/projects/WorkboardController.js' => 'b9d0c2f3', 'rsrc/js/application/projects/WorkboardDropEffect.js' => '8e0aa661', 'rsrc/js/application/projects/WorkboardHeader.js' => '111bfd2d', 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => 'ebe83a6b', 'rsrc/js/application/projects/WorkboardOrderTemplate.js' => '03e8891f', 'rsrc/js/application/projects/behavior-project-boards.js' => '58cb6a88', 'rsrc/js/application/projects/behavior-project-create.js' => '34c53422', 'rsrc/js/application/projects/behavior-reorder-columns.js' => '8ac32fd9', 'rsrc/js/application/releeph/releeph-preview-branch.js' => '75184d68', 'rsrc/js/application/releeph/releeph-request-state-change.js' => '9f081f05', 'rsrc/js/application/releeph/releeph-request-typeahead.js' => 'aa3a100c', 'rsrc/js/application/repository/repository-crossreference.js' => '6337cf26', 'rsrc/js/application/search/behavior-reorder-profile-menu-items.js' => 'e5bdb730', 'rsrc/js/application/search/behavior-reorder-queries.js' => 'b86f297f', 'rsrc/js/application/transactions/behavior-comment-actions.js' => '4dffaeb2', 'rsrc/js/application/transactions/behavior-reorder-configs.js' => '4842f137', 'rsrc/js/application/transactions/behavior-reorder-fields.js' => '0ad8d31f', 'rsrc/js/application/transactions/behavior-show-older-transactions.js' => '8b5c7d65', 'rsrc/js/application/transactions/behavior-transaction-comment-form.js' => '2bdadf1a', 'rsrc/js/application/transactions/behavior-transaction-list.js' => '9cec214e', 'rsrc/js/application/trigger/TriggerRule.js' => '41b7b4f6', 'rsrc/js/application/trigger/TriggerRuleControl.js' => '5faf27b9', 'rsrc/js/application/trigger/TriggerRuleEditor.js' => 'b49fd60c', 'rsrc/js/application/trigger/TriggerRuleType.js' => '4feea7d3', 'rsrc/js/application/trigger/trigger-rule-editor.js' => '398fdf13', 'rsrc/js/application/typeahead/behavior-typeahead-browse.js' => '70245195', 'rsrc/js/application/typeahead/behavior-typeahead-search.js' => '7b139193', 'rsrc/js/application/uiexample/gesture-example.js' => '242dedd0', 'rsrc/js/application/uiexample/notification-example.js' => '29819b75', 'rsrc/js/core/Busy.js' => '5202e831', 'rsrc/js/core/DragAndDropFileUpload.js' => '4370900d', 'rsrc/js/core/DraggableList.js' => '0169e425', 'rsrc/js/core/Favicon.js' => '7930776a', 'rsrc/js/core/FileUpload.js' => 'ab85e184', 'rsrc/js/core/Hovercard.js' => '074f0783', 'rsrc/js/core/KeyboardShortcut.js' => '1a844c06', 'rsrc/js/core/KeyboardShortcutManager.js' => '81debc48', 'rsrc/js/core/MultirowRowManager.js' => '5b54c823', 'rsrc/js/core/Notification.js' => 'a9b91e3f', 'rsrc/js/core/Prefab.js' => '5793d835', 'rsrc/js/core/ShapedRequest.js' => '995f5102', 'rsrc/js/core/TextAreaUtils.js' => 'f340a484', 'rsrc/js/core/Title.js' => '43bc9360', 'rsrc/js/core/ToolTip.js' => '83754533', 'rsrc/js/core/behavior-audio-source.js' => '3dc5ad43', 'rsrc/js/core/behavior-autofocus.js' => '65bb0011', 'rsrc/js/core/behavior-badge-view.js' => '92cdd7b6', 'rsrc/js/core/behavior-bulk-editor.js' => 'aa6d2308', 'rsrc/js/core/behavior-choose-control.js' => '04f8a1e3', 'rsrc/js/core/behavior-copy.js' => 'cf32921f', 'rsrc/js/core/behavior-detect-timezone.js' => '78bc5d94', 'rsrc/js/core/behavior-device.js' => '0cf79f45', 'rsrc/js/core/behavior-drag-and-drop-textarea.js' => '7ad020a5', 'rsrc/js/core/behavior-fancy-datepicker.js' => '956f3eeb', 'rsrc/js/core/behavior-form.js' => '55d7b788', 'rsrc/js/core/behavior-gesture.js' => 'b58d1a2a', 'rsrc/js/core/behavior-global-drag-and-drop.js' => '1cab0e9a', 'rsrc/js/core/behavior-high-security-warning.js' => 'dae2d55b', 'rsrc/js/core/behavior-history-install.js' => '6a1583a8', 'rsrc/js/core/behavior-hovercard.js' => '6c379000', 'rsrc/js/core/behavior-keyboard-pager.js' => '1325b731', 'rsrc/js/core/behavior-keyboard-shortcuts.js' => '42c44e8b', 'rsrc/js/core/behavior-lightbox-attachments.js' => 'c7e748bf', 'rsrc/js/core/behavior-line-linker.js' => '590e6527', 'rsrc/js/core/behavior-linked-container.js' => '74446546', 'rsrc/js/core/behavior-more.js' => '506aa3f4', 'rsrc/js/core/behavior-object-selector.js' => '98ef467f', - 'rsrc/js/core/behavior-oncopy.js' => 'ff7b3f22', + 'rsrc/js/core/behavior-oncopy.js' => 'da8f5259', 'rsrc/js/core/behavior-phabricator-remarkup-assist.js' => '54262396', 'rsrc/js/core/behavior-read-only-warning.js' => 'b9109f8f', 'rsrc/js/core/behavior-redirect.js' => '407ee861', 'rsrc/js/core/behavior-refresh-csrf.js' => '46116c01', 'rsrc/js/core/behavior-remarkup-load-image.js' => '202bfa3f', 'rsrc/js/core/behavior-remarkup-preview.js' => 'd8a86cfb', 'rsrc/js/core/behavior-reorder-applications.js' => 'aa371860', 'rsrc/js/core/behavior-reveal-content.js' => 'b105a3a6', 'rsrc/js/core/behavior-scrollbar.js' => '92388bae', 'rsrc/js/core/behavior-search-typeahead.js' => '1cb7d027', 'rsrc/js/core/behavior-select-content.js' => 'e8240b50', 'rsrc/js/core/behavior-select-on-click.js' => '66365ee2', 'rsrc/js/core/behavior-setup-check-https.js' => '01384686', 'rsrc/js/core/behavior-time-typeahead.js' => '5803b9e7', 'rsrc/js/core/behavior-toggle-class.js' => '32db8374', 'rsrc/js/core/behavior-tokenizer.js' => '3b4899b0', 'rsrc/js/core/behavior-tooltip.js' => '73ecc1f8', 'rsrc/js/core/behavior-user-menu.js' => '60cd9241', 'rsrc/js/core/behavior-watch-anchor.js' => 'a77e2cbd', 'rsrc/js/core/behavior-workflow.js' => '9623adc1', 'rsrc/js/core/darkconsole/DarkLog.js' => '3b869402', 'rsrc/js/core/darkconsole/DarkMessage.js' => '26cd4b73', 'rsrc/js/core/darkconsole/behavior-dark-console.js' => '457f4d16', 'rsrc/js/core/phtize.js' => '2f1db1ed', 'rsrc/js/phui/behavior-phui-dropdown-menu.js' => '5cf0501a', 'rsrc/js/phui/behavior-phui-file-upload.js' => 'e150bd50', 'rsrc/js/phui/behavior-phui-selectable-list.js' => 'b26a41e4', 'rsrc/js/phui/behavior-phui-submenu.js' => 'b5e9bff9', 'rsrc/js/phui/behavior-phui-tab-group.js' => '242aa08b', 'rsrc/js/phui/behavior-phui-timer-control.js' => 'f84bcbf4', 'rsrc/js/phuix/PHUIXActionListView.js' => 'c68f183f', 'rsrc/js/phuix/PHUIXActionView.js' => 'a8f573a9', 'rsrc/js/phuix/PHUIXAutocomplete.js' => '2fbe234d', 'rsrc/js/phuix/PHUIXButtonView.js' => '55a24e84', - 'rsrc/js/phuix/PHUIXDropdownMenu.js' => '7acfd98b', + 'rsrc/js/phuix/PHUIXDropdownMenu.js' => 'b557770a', 'rsrc/js/phuix/PHUIXExample.js' => 'c2c500a7', 'rsrc/js/phuix/PHUIXFormControl.js' => '38c1f3fb', 'rsrc/js/phuix/PHUIXFormationColumnView.js' => '4bcc1f78', 'rsrc/js/phuix/PHUIXFormationFlankView.js' => '6648270a', 'rsrc/js/phuix/PHUIXFormationView.js' => 'cef53b3e', 'rsrc/js/phuix/PHUIXIconView.js' => 'a5257c4e', ), 'symbols' => array( 'almanac-css' => '2e050f4f', 'aphront-bars' => '4a327b4a', 'aphront-dark-console-css' => '7f06cda2', 'aphront-dialog-view-css' => '6f4ea703', 'aphront-list-filter-view-css' => 'feb64255', 'aphront-multi-column-view-css' => 'fbc00ba3', 'aphront-panel-view-css' => '46923d46', 'aphront-table-view-css' => '0bb61df1', 'aphront-tokenizer-control-css' => '34e2a838', 'aphront-tooltip-css' => 'e3f2412f', 'aphront-typeahead-control-css' => '8779483d', 'application-search-view-css' => '0f7c06d8', 'auth-css' => 'c2f23d74', 'bulk-job-css' => '73af99f5', 'conduit-api-css' => 'ce2cfc41', 'config-options-css' => '16c920ae', 'conpherence-color-css' => 'b17746b0', 'conpherence-durable-column-view' => '2d57072b', 'conpherence-header-pane-css' => 'c9a3db8e', 'conpherence-menu-css' => '67f4680d', 'conpherence-message-pane-css' => 'd244db1e', 'conpherence-notification-css' => '6a3d4e58', 'conpherence-participant-pane-css' => '69e0058a', 'conpherence-thread-manager' => 'aec8e38c', 'conpherence-transaction-css' => '3a3f5e7e', 'd3' => '9d068042', 'diff-tree-view-css' => 'e2d3e222', - 'differential-changeset-view-css' => 'a5cc67cf', + 'differential-changeset-view-css' => '60c3d405', 'differential-core-view-css' => '7300a73e', 'differential-revision-add-comment-css' => '7e5900d9', 'differential-revision-comment-css' => '7dbc8d1d', 'differential-revision-history-css' => '8aa3eac5', 'differential-revision-list-css' => '93d2df7d', 'differential-table-of-contents-css' => 'bba788b9', 'diffusion-css' => 'b54c77b0', 'diffusion-icons-css' => '23b31a1b', 'diffusion-readme-css' => 'b68a76e4', 'diffusion-repository-css' => 'b89e8c6c', 'diviner-shared-css' => '4bd263b0', 'font-fontawesome' => '3883938a', 'font-lato' => '23631304', 'global-drag-and-drop-css' => '1d2713a4', 'harbormaster-css' => '8dfe16b2', 'herald-css' => '648d39e2', 'herald-rule-editor' => '2633bef7', 'herald-test-css' => 'e004176f', 'inline-comment-summary-css' => '81eb368d', 'javelin-aphlict' => '022516b4', 'javelin-behavior' => '1b6acc2a', 'javelin-behavior-aphlict-dropdown' => 'e9a2940f', 'javelin-behavior-aphlict-listen' => '4e61fa88', 'javelin-behavior-aphlict-status' => 'c3703a16', 'javelin-behavior-aphront-basic-tokenizer' => '3b4899b0', 'javelin-behavior-aphront-drag-and-drop-textarea' => '7ad020a5', 'javelin-behavior-aphront-form-disable-on-submit' => '55d7b788', 'javelin-behavior-aphront-more' => '506aa3f4', 'javelin-behavior-audio-source' => '3dc5ad43', 'javelin-behavior-audit-preview' => 'b7b73831', 'javelin-behavior-badge-view' => '92cdd7b6', 'javelin-behavior-bulk-editor' => 'aa6d2308', 'javelin-behavior-bulk-job-reload' => '3829a3cf', 'javelin-behavior-calendar-month-view' => '158c64e0', 'javelin-behavior-choose-control' => '04f8a1e3', 'javelin-behavior-comment-actions' => '4dffaeb2', 'javelin-behavior-config-reorder-fields' => '2539f834', 'javelin-behavior-conpherence-menu' => '8c2ed2bf', 'javelin-behavior-conpherence-participant-pane' => '43ba89a2', 'javelin-behavior-conpherence-pontificate' => '4ae58b5a', 'javelin-behavior-conpherence-search' => '91befbcc', 'javelin-behavior-countdown-timer' => '6a162524', 'javelin-behavior-dark-console' => '457f4d16', 'javelin-behavior-dashboard-async-panel' => '9c01e364', 'javelin-behavior-dashboard-move-panels' => 'a2ab19be', 'javelin-behavior-dashboard-query-panel-select' => '1e413dc9', 'javelin-behavior-dashboard-tab-panel' => '0116d3e8', 'javelin-behavior-day-view' => '727a5a61', 'javelin-behavior-desktop-notifications-control' => '070679fe', 'javelin-behavior-detect-timezone' => '78bc5d94', 'javelin-behavior-device' => '0cf79f45', 'javelin-behavior-differential-diff-radios' => '925fe8cd', 'javelin-behavior-differential-populate' => 'b86ef6c2', 'javelin-behavior-diffusion-commit-branches' => '4b671572', 'javelin-behavior-diffusion-commit-graph' => 'ef836bf2', 'javelin-behavior-diffusion-locate-file' => '87428eb2', 'javelin-behavior-diffusion-pull-lastmodified' => 'c715c123', 'javelin-behavior-document-engine' => '243d6c22', 'javelin-behavior-doorkeeper-tag' => '6a85bc5a', 'javelin-behavior-drydock-live-operation-status' => '47a0728b', 'javelin-behavior-durable-column' => 'fa6f30b2', 'javelin-behavior-editengine-reorder-configs' => '4842f137', 'javelin-behavior-editengine-reorder-fields' => '0ad8d31f', 'javelin-behavior-event-all-day' => '0b1bc990', 'javelin-behavior-fancy-datepicker' => '956f3eeb', 'javelin-behavior-global-drag-and-drop' => '1cab0e9a', 'javelin-behavior-harbormaster-log' => 'b347a301', 'javelin-behavior-herald-rule-editor' => '0922e81d', 'javelin-behavior-high-security-warning' => 'dae2d55b', 'javelin-behavior-history-install' => '6a1583a8', 'javelin-behavior-icon-composer' => '38a6cedb', 'javelin-behavior-launch-icon-composer' => 'a17b84f1', 'javelin-behavior-lightbox-attachments' => 'c7e748bf', 'javelin-behavior-line-chart' => 'ad258e28', 'javelin-behavior-linked-container' => '74446546', 'javelin-behavior-maniphest-batch-selector' => '139ef688', 'javelin-behavior-maniphest-list-editor' => 'c687e867', 'javelin-behavior-owners-path-editor' => 'ff688a7a', 'javelin-behavior-passphrase-credential-control' => '48fe33d0', 'javelin-behavior-phabricator-autofocus' => '65bb0011', 'javelin-behavior-phabricator-clipboard-copy' => 'cf32921f', 'javelin-behavior-phabricator-gesture' => 'b58d1a2a', 'javelin-behavior-phabricator-gesture-example' => '242dedd0', 'javelin-behavior-phabricator-keyboard-pager' => '1325b731', 'javelin-behavior-phabricator-keyboard-shortcuts' => '42c44e8b', 'javelin-behavior-phabricator-line-linker' => '590e6527', 'javelin-behavior-phabricator-notification-example' => '29819b75', 'javelin-behavior-phabricator-object-selector' => '98ef467f', - 'javelin-behavior-phabricator-oncopy' => 'ff7b3f22', + 'javelin-behavior-phabricator-oncopy' => 'da8f5259', 'javelin-behavior-phabricator-remarkup-assist' => '54262396', 'javelin-behavior-phabricator-reveal-content' => 'b105a3a6', 'javelin-behavior-phabricator-search-typeahead' => '1cb7d027', 'javelin-behavior-phabricator-show-older-transactions' => '8b5c7d65', 'javelin-behavior-phabricator-tooltips' => '73ecc1f8', 'javelin-behavior-phabricator-transaction-comment-form' => '2bdadf1a', 'javelin-behavior-phabricator-transaction-list' => '9cec214e', 'javelin-behavior-phabricator-watch-anchor' => 'a77e2cbd', 'javelin-behavior-pholio-mock-edit' => '3eed1f2b', 'javelin-behavior-pholio-mock-view' => '5aa1544e', 'javelin-behavior-phui-dropdown-menu' => '5cf0501a', 'javelin-behavior-phui-file-upload' => 'e150bd50', 'javelin-behavior-phui-hovercards' => '6c379000', 'javelin-behavior-phui-selectable-list' => 'b26a41e4', 'javelin-behavior-phui-submenu' => 'b5e9bff9', 'javelin-behavior-phui-tab-group' => '242aa08b', 'javelin-behavior-phui-timer-control' => 'f84bcbf4', 'javelin-behavior-phuix-example' => 'c2c500a7', 'javelin-behavior-policy-control' => '0eaa33a9', 'javelin-behavior-policy-rule-editor' => '9347f172', 'javelin-behavior-project-boards' => '58cb6a88', 'javelin-behavior-project-create' => '34c53422', 'javelin-behavior-quicksand-blacklist' => '5a6f6a06', 'javelin-behavior-read-only-warning' => 'b9109f8f', 'javelin-behavior-redirect' => '407ee861', 'javelin-behavior-refresh-csrf' => '46116c01', 'javelin-behavior-releeph-preview-branch' => '75184d68', 'javelin-behavior-releeph-request-state-change' => '9f081f05', 'javelin-behavior-releeph-request-typeahead' => 'aa3a100c', 'javelin-behavior-remarkup-load-image' => '202bfa3f', 'javelin-behavior-remarkup-preview' => 'd8a86cfb', 'javelin-behavior-reorder-applications' => 'aa371860', 'javelin-behavior-reorder-columns' => '8ac32fd9', 'javelin-behavior-reorder-profile-menu-items' => 'e5bdb730', 'javelin-behavior-repository-crossreference' => '6337cf26', 'javelin-behavior-scrollbar' => '92388bae', 'javelin-behavior-search-reorder-queries' => 'b86f297f', 'javelin-behavior-select-content' => 'e8240b50', 'javelin-behavior-select-on-click' => '66365ee2', 'javelin-behavior-setup-check-https' => '01384686', 'javelin-behavior-stripe-payment-form' => '02cb4398', 'javelin-behavior-test-payment-form' => '4a7fb02b', 'javelin-behavior-time-typeahead' => '5803b9e7', 'javelin-behavior-toggle-class' => '32db8374', 'javelin-behavior-toggle-widget' => '8f959ad0', 'javelin-behavior-trigger-rule-editor' => '398fdf13', 'javelin-behavior-typeahead-browse' => '70245195', 'javelin-behavior-typeahead-search' => '7b139193', 'javelin-behavior-user-menu' => '60cd9241', 'javelin-behavior-view-placeholder' => 'a9942052', 'javelin-behavior-workflow' => '9623adc1', 'javelin-chart' => '52e3ff03', 'javelin-chart-curtain-view' => '86954222', 'javelin-chart-function-label' => '81de1dab', 'javelin-color' => '78f811c9', 'javelin-cookie' => '05d290ef', 'javelin-diffusion-locate-file-source' => '94243d89', 'javelin-dom' => '94681e22', 'javelin-dynval' => '202a2e85', 'javelin-event' => 'c03f2fb4', 'javelin-fx' => '34450586', 'javelin-history' => '030b4f7a', 'javelin-install' => '5902260c', 'javelin-json' => '541f81c3', 'javelin-leader' => '0d2490ce', 'javelin-magical-init' => '98e6504a', 'javelin-mask' => '7c4d8998', 'javelin-quicksand' => 'd3799cb4', 'javelin-reactor' => '1c850a26', 'javelin-reactor-dom' => '6cfa0008', 'javelin-reactor-node-calmer' => '225bbb98', 'javelin-reactornode' => '72960bc1', 'javelin-request' => '84e6891f', 'javelin-resource' => '740956e1', 'javelin-routable' => '6a18c42e', 'javelin-router' => '32755edb', 'javelin-scrollbar' => 'a43ae2ae', 'javelin-sound' => 'd4cc2d2a', 'javelin-stratcom' => '0889b835', 'javelin-tokenizer' => '89a1ae3a', 'javelin-typeahead' => 'a4356cde', 'javelin-typeahead-composite-source' => '22ee68a5', 'javelin-typeahead-normalizer' => 'a241536a', 'javelin-typeahead-ondemand-source' => '23387297', 'javelin-typeahead-preloaded-source' => '5a79f6c3', 'javelin-typeahead-source' => '8badee71', 'javelin-typeahead-static-source' => '80bff3af', 'javelin-uri' => '2e255291', 'javelin-util' => 'edb4d8c9', 'javelin-vector' => 'e9c80beb', 'javelin-view' => '289bf236', 'javelin-view-html' => 'f8c4e135', 'javelin-view-interpreter' => '876506b6', 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', 'javelin-workboard-board' => 'b46d88c5', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '84f82dad', 'javelin-workboard-column' => 'c3d24e63', 'javelin-workboard-controller' => 'b9d0c2f3', 'javelin-workboard-drop-effect' => '8e0aa661', 'javelin-workboard-header' => '111bfd2d', 'javelin-workboard-header-template' => 'ebe83a6b', 'javelin-workboard-order-template' => '03e8891f', 'javelin-workflow' => '945ff654', 'maniphest-report-css' => '3d53188b', 'maniphest-task-edit-css' => '272daa84', 'maniphest-task-summary-css' => '61d1667e', 'multirow-row-manager' => '5b54c823', 'owners-path-editor' => '2a8b62d9', 'owners-path-editor-css' => 'fa7c13ef', 'paste-css' => 'b37bcd38', 'path-typeahead' => 'ad486db3', 'people-picture-menu-item-css' => 'fe8e07cf', 'people-profile-css' => '2ea2daa1', 'phabricator-action-list-view-css' => '1b0085b2', 'phabricator-busy' => '5202e831', 'phabricator-chatlog-css' => 'abdc76ee', 'phabricator-content-source-view-css' => 'cdf0d579', 'phabricator-core-css' => '1b29ed61', 'phabricator-countdown-css' => 'bff8012f', 'phabricator-darklog' => '3b869402', 'phabricator-darkmessage' => '26cd4b73', 'phabricator-dashboard-css' => '5a205b9d', - 'phabricator-diff-changeset' => '700bf848', - 'phabricator-diff-changeset-list' => '6e668c5b', - 'phabricator-diff-inline' => '9a3963e0', + 'phabricator-diff-changeset' => '68d963eb', + 'phabricator-diff-changeset-list' => 'ac403c32', + 'phabricator-diff-inline' => 'b00168c1', 'phabricator-diff-path-view' => '8207abf9', 'phabricator-diff-tree-view' => '5d83623b', 'phabricator-drag-and-drop-file-upload' => '4370900d', 'phabricator-draggable-list' => '0169e425', 'phabricator-fatal-config-template-css' => '20babf50', 'phabricator-favicon' => '7930776a', 'phabricator-feed-css' => 'd8b6e3f8', 'phabricator-file-upload' => 'ab85e184', 'phabricator-flag-css' => '2b77be8d', 'phabricator-keyboard-shortcut' => '1a844c06', 'phabricator-keyboard-shortcut-manager' => '81debc48', 'phabricator-main-menu-view' => 'bcec20f0', 'phabricator-nav-view-css' => '423f92cc', 'phabricator-notification' => 'a9b91e3f', 'phabricator-notification-css' => '30240bd2', 'phabricator-notification-menu-css' => '4df1ee30', 'phabricator-object-selector-css' => 'ee77366f', 'phabricator-phtize' => '2f1db1ed', 'phabricator-prefab' => '5793d835', 'phabricator-remarkup-css' => 'c286eaef', 'phabricator-search-results-css' => '9ea70ace', 'phabricator-shaped-request' => '995f5102', 'phabricator-slowvote-css' => '1694baed', 'phabricator-source-code-view-css' => '03d7ac28', 'phabricator-standard-page-view' => 'a374f94c', 'phabricator-textareautils' => 'f340a484', 'phabricator-title' => '43bc9360', 'phabricator-tooltip' => '83754533', 'phabricator-ui-example-css' => 'b4795059', - 'phabricator-zindex-css' => '612e9522', + 'phabricator-zindex-css' => 'ac3bfcd4', 'phame-css' => 'bb442327', 'pholio-css' => '88ef5ef1', 'pholio-edit-css' => '4df55b3b', 'pholio-inline-comments-css' => '722b48c2', 'phortune-credit-card-form' => 'd12d214f', 'phortune-credit-card-form-css' => '3b9868a8', 'phortune-css' => '508a1a5e', 'phortune-invoice-css' => '4436b241', 'phrequent-css' => 'bd79cc67', 'phriction-document-css' => '03380da0', 'phui-action-panel-css' => '6c386cbf', 'phui-badge-view-css' => '666e25ad', 'phui-basic-nav-view-css' => '56ebd66d', 'phui-big-info-view-css' => '362ad37b', 'phui-box-css' => '5ed3b8cb', 'phui-bulk-editor-css' => '374d5e30', 'phui-button-bar-css' => 'a4aa75c4', 'phui-button-css' => 'ea704902', 'phui-button-simple-css' => '1ff278aa', 'phui-calendar-css' => 'f11073aa', 'phui-calendar-day-css' => '9597d706', 'phui-calendar-list-css' => 'ccd7e4e2', 'phui-calendar-month-css' => 'cb758c42', 'phui-chart-css' => '14df9ae3', 'phui-cms-css' => '8c05c41e', 'phui-comment-form-css' => '68a2d99a', 'phui-comment-panel-css' => 'ec4e31c0', 'phui-crumbs-view-css' => '614f43cf', 'phui-curtain-object-ref-view-css' => '12404744', 'phui-curtain-view-css' => '68c5efb6', 'phui-document-summary-view-css' => 'b068eed1', 'phui-document-view-css' => '52b748a5', 'phui-document-view-pro-css' => 'b9613a10', 'phui-feed-story-css' => 'a0c05029', 'phui-font-icon-base-css' => '303c9b87', 'phui-fontkit-css' => '1ec937e5', 'phui-form-css' => '1f177cb7', 'phui-form-view-css' => '01b796c0', 'phui-formation-view-css' => 'd2dec8ed', 'phui-head-thing-view-css' => 'd7f293df', 'phui-header-view-css' => '36c86a58', 'phui-hovercard' => '074f0783', 'phui-hovercard-view-css' => '6ca90fa0', 'phui-icon-set-selector-css' => '7aa5f3ec', 'phui-icon-view-css' => '4cbc684a', 'phui-image-mask-css' => '62c7f4d2', 'phui-info-view-css' => 'a10a909b', - 'phui-inline-comment-view-css' => '48acce5b', + 'phui-inline-comment-view-css' => 'd5749acc', 'phui-invisible-character-view-css' => 'c694c4a4', 'phui-left-right-css' => '68513c34', 'phui-lightbox-css' => '4ebf22da', 'phui-list-view-css' => '2f253c22', 'phui-object-box-css' => 'b8d7eea0', 'phui-oi-big-ui-css' => 'fa74cc35', 'phui-oi-color-css' => 'b517bfa0', 'phui-oi-drag-ui-css' => 'da15d3dc', 'phui-oi-flush-ui-css' => '490e2e2e', 'phui-oi-list-view-css' => 'd7723ecc', 'phui-oi-simple-ui-css' => '6a30fa46', 'phui-pager-css' => 'd022c7ad', 'phui-pinboard-view-css' => '1f08f5d8', 'phui-policy-section-view-css' => '139fdc64', - 'phui-property-list-view-css' => '9c477af1', + 'phui-property-list-view-css' => '5adf7078', 'phui-remarkup-preview-css' => '91767007', 'phui-segment-bar-view-css' => '5166b370', 'phui-spacing-css' => 'b05cadc3', 'phui-status-list-view-css' => 'e5ff8be0', 'phui-tag-view-css' => '8519160a', 'phui-theme-css' => '35883b37', 'phui-timeline-view-css' => '2d32d7a9', 'phui-two-column-view-css' => 'f96d319f', 'phui-workboard-color-css' => 'e86de308', 'phui-workboard-view-css' => '74fc9d98', 'phui-workcard-view-css' => '913441b6', 'phui-workpanel-view-css' => '3ae89b20', 'phuix-action-list-view' => 'c68f183f', 'phuix-action-view' => 'a8f573a9', 'phuix-autocomplete' => '2fbe234d', 'phuix-button-view' => '55a24e84', - 'phuix-dropdown-menu' => '7acfd98b', + 'phuix-dropdown-menu' => 'b557770a', 'phuix-form-control-view' => '38c1f3fb', 'phuix-formation-column-view' => '4bcc1f78', 'phuix-formation-flank-view' => '6648270a', 'phuix-formation-view' => 'cef53b3e', 'phuix-icon-view' => 'a5257c4e', 'policy-css' => 'ceb56a08', 'policy-edit-css' => '8794e2ed', 'policy-transaction-detail-css' => 'c02b8384', 'ponder-view-css' => '05a09d0a', 'project-card-view-css' => '4e7371cd', 'project-triggers-css' => 'cd9c8bb9', 'project-view-css' => '567858b3', 'releeph-core' => 'f81ff2db', 'releeph-preview-branch' => '22db5c07', 'releeph-request-differential-create-dialog' => '0ac1ea31', 'releeph-request-typeahead-css' => 'bce37359', 'setup-issue-css' => '5eed85b2', 'sprite-login-css' => '18b368a6', 'sprite-tokens-css' => 'f1896dc5', 'syntax-default-css' => '055fc231', - 'syntax-highlighting-css' => '220b85f9', + 'syntax-highlighting-css' => '548567f6', 'tokens-css' => 'ce5a50bd', 'trigger-rule' => '41b7b4f6', 'trigger-rule-control' => '5faf27b9', 'trigger-rule-editor' => 'b49fd60c', 'trigger-rule-type' => '4feea7d3', 'typeahead-browse-css' => 'b7ed02d2', 'unhandled-exception-css' => '9ecfc00d', ), 'requires' => array( '0116d3e8' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', ), '01384686' => array( 'javelin-behavior', 'javelin-uri', 'phabricator-notification', ), '0169e425' => array( 'javelin-install', 'javelin-dom', 'javelin-stratcom', 'javelin-util', 'javelin-vector', 'javelin-magical-init', ), '022516b4' => array( 'javelin-install', 'javelin-util', 'javelin-websocket', 'javelin-leader', 'javelin-json', ), '02cb4398' => array( 'javelin-behavior', 'javelin-dom', 'phortune-credit-card-form', ), '030b4f7a' => array( 'javelin-stratcom', 'javelin-install', 'javelin-uri', 'javelin-util', ), '0392a5d8' => array( 'javelin-install', ), '03e8891f' => array( 'javelin-install', ), '04f8a1e3' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'javelin-workflow', ), '05d290ef' => array( 'javelin-install', 'javelin-util', ), '070679fe' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'javelin-uri', 'phabricator-notification', ), '074f0783' => array( 'javelin-install', 'javelin-dom', 'javelin-vector', 'javelin-request', 'javelin-uri', ), '0889b835' => array( 'javelin-install', 'javelin-event', 'javelin-util', 'javelin-magical-init', ), '0922e81d' => array( 'herald-rule-editor', 'javelin-behavior', ), '0ad8d31f' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', 'phabricator-draggable-list', ), '0cf79f45' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'javelin-vector', 'javelin-install', ), '0d2490ce' => array( 'javelin-install', ), '0eaa33a9' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'phuix-dropdown-menu', 'phuix-action-list-view', 'phuix-action-view', 'javelin-workflow', 'phuix-icon-view', ), '111bfd2d' => array( 'javelin-install', ), '1325b731' => array( 'javelin-behavior', 'javelin-uri', 'phabricator-keyboard-shortcut', ), '139ef688' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', 'javelin-util', ), '1a844c06' => array( 'javelin-install', 'javelin-util', 'phabricator-keyboard-shortcut-manager', ), '1b6acc2a' => array( 'javelin-magical-init', 'javelin-util', ), '1c850a26' => array( 'javelin-install', 'javelin-util', ), '1cab0e9a' => array( 'javelin-behavior', 'javelin-dom', 'javelin-uri', 'javelin-mask', 'phabricator-drag-and-drop-file-upload', ), '1cb7d027' => array( 'javelin-behavior', 'javelin-typeahead-ondemand-source', 'javelin-typeahead', 'javelin-dom', 'javelin-uri', 'javelin-util', 'javelin-stratcom', 'phabricator-prefab', 'phuix-icon-view', ), '1e413dc9' => array( 'javelin-behavior', 'javelin-dom', ), '1ff278aa' => array( 'phui-button-css', ), '202a2e85' => array( 'javelin-install', 'javelin-reactornode', 'javelin-util', 'javelin-reactor', ), '202bfa3f' => array( 'javelin-behavior', 'javelin-request', ), - '220b85f9' => array( - 'syntax-default-css', - ), '225bbb98' => array( 'javelin-install', 'javelin-reactor', 'javelin-util', ), '22ee68a5' => array( 'javelin-install', 'javelin-typeahead-source', 'javelin-util', ), 23387297 => array( 'javelin-install', 'javelin-util', 'javelin-request', 'javelin-typeahead-source', ), 23631304 => array( 'phui-fontkit-css', ), '242aa08b' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), '242dedd0' => array( 'javelin-stratcom', 'javelin-behavior', 'javelin-vector', 'javelin-dom', ), '243d6c22' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', ), '2539f834' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'javelin-json', 'phabricator-draggable-list', ), '2633bef7' => array( 'multirow-row-manager', 'javelin-install', 'javelin-util', 'javelin-dom', 'javelin-stratcom', 'javelin-json', 'phabricator-prefab', ), '289bf236' => array( 'javelin-install', 'javelin-util', ), '29819b75' => array( 'phabricator-notification', 'javelin-stratcom', 'javelin-behavior', ), '2a8b62d9' => array( 'multirow-row-manager', 'javelin-install', 'path-typeahead', 'javelin-dom', 'javelin-util', 'phabricator-prefab', 'phuix-form-control-view', ), '2bdadf1a' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'javelin-request', 'phabricator-shaped-request', ), '2e255291' => array( 'javelin-install', 'javelin-util', 'javelin-stratcom', ), '2f1db1ed' => array( 'javelin-util', ), '2fbe234d' => array( 'javelin-install', 'javelin-dom', 'phuix-icon-view', 'phabricator-prefab', ), '308f9fe4' => array( 'javelin-install', 'javelin-util', ), '32755edb' => array( 'javelin-install', 'javelin-util', ), '32db8374' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), 34450586 => array( 'javelin-color', 'javelin-install', 'javelin-util', ), '34c53422' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', 'javelin-workflow', ), '34e2a838' => array( 'aphront-typeahead-control-css', 'phui-tag-view-css', ), '3829a3cf' => array( 'javelin-behavior', 'javelin-uri', ), '38a6cedb' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', ), '38c1f3fb' => array( 'javelin-install', 'javelin-dom', ), '398fdf13' => array( 'javelin-behavior', 'trigger-rule-editor', 'trigger-rule', 'trigger-rule-type', ), '3ae89b20' => array( 'phui-workcard-view-css', ), '3b4899b0' => array( 'javelin-behavior', 'phabricator-prefab', ), '3dc5ad43' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-vector', 'javelin-dom', ), '3eed1f2b' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'javelin-workflow', 'javelin-quicksand', 'phabricator-phtize', 'phabricator-drag-and-drop-file-upload', 'phabricator-draggable-list', ), '407ee861' => array( 'javelin-behavior', 'javelin-uri', ), '42c44e8b' => array( 'javelin-behavior', 'javelin-workflow', 'javelin-json', 'javelin-dom', 'phabricator-keyboard-shortcut', ), '4370900d' => array( 'javelin-install', 'javelin-util', 'javelin-request', 'javelin-dom', 'javelin-uri', 'phabricator-file-upload', ), '43ba89a2' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', 'javelin-workflow', 'javelin-util', 'phabricator-notification', 'conpherence-thread-manager', ), '43bc9360' => array( 'javelin-install', ), '457f4d16' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-util', 'javelin-dom', 'javelin-request', 'phabricator-keyboard-shortcut', 'phabricator-darklog', 'phabricator-darkmessage', ), '46116c01' => array( 'javelin-request', 'javelin-behavior', 'javelin-dom', 'javelin-router', 'javelin-util', 'phabricator-busy', ), '47a0728b' => array( 'javelin-behavior', 'javelin-dom', 'javelin-request', ), '4842f137' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', 'phabricator-draggable-list', ), '48fe33d0' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', 'javelin-workflow', 'javelin-util', 'javelin-uri', ), '490e2e2e' => array( 'phui-oi-list-view-css', ), '4a7fb02b' => array( 'javelin-behavior', 'javelin-dom', 'phortune-credit-card-form', ), '4ae58b5a' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'javelin-workflow', 'javelin-stratcom', 'conpherence-thread-manager', ), '4b671572' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'javelin-request', ), '4bcc1f78' => array( 'javelin-install', 'javelin-dom', ), '4dffaeb2' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', 'phuix-form-control-view', 'phuix-icon-view', 'javelin-behavior-phabricator-gesture', ), '4e61fa88' => array( 'javelin-behavior', 'javelin-aphlict', 'javelin-stratcom', 'javelin-request', 'javelin-uri', 'javelin-dom', 'javelin-json', 'javelin-router', 'javelin-util', 'javelin-leader', 'javelin-sound', 'phabricator-notification', ), '4feea7d3' => array( 'trigger-rule-control', ), '506aa3f4' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), '5202e831' => array( 'javelin-install', 'javelin-dom', 'javelin-fx', ), '52e3ff03' => array( 'phui-chart-css', 'd3', 'javelin-chart-curtain-view', 'javelin-chart-function-label', ), '541f81c3' => array( 'javelin-install', ), 54262396 => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'phabricator-phtize', 'phabricator-textareautils', 'javelin-workflow', 'javelin-vector', 'phuix-autocomplete', 'javelin-mask', ), + '548567f6' => array( + 'syntax-default-css', + ), '55a24e84' => array( 'javelin-install', 'javelin-dom', ), '55d7b788' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), '5793d835' => array( 'javelin-install', 'javelin-util', 'javelin-dom', 'javelin-typeahead', 'javelin-tokenizer', 'javelin-typeahead-preloaded-source', 'javelin-typeahead-ondemand-source', 'javelin-dom', 'javelin-stratcom', 'javelin-util', ), '5803b9e7' => array( 'javelin-behavior', 'javelin-util', 'javelin-dom', 'javelin-stratcom', 'javelin-vector', 'javelin-typeahead-static-source', ), '58cb6a88' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'javelin-vector', 'javelin-stratcom', 'javelin-workflow', 'javelin-workboard-controller', 'javelin-workboard-drop-effect', ), '5902260c' => array( 'javelin-util', 'javelin-magical-init', ), '590e6527' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'javelin-history', ), '5a6f6a06' => array( 'javelin-behavior', 'javelin-quicksand', ), '5a79f6c3' => array( 'javelin-install', 'javelin-util', 'javelin-request', 'javelin-typeahead-source', ), '5aa1544e' => array( 'javelin-behavior', 'javelin-util', 'javelin-stratcom', 'javelin-dom', 'javelin-vector', 'javelin-magical-init', 'javelin-request', 'javelin-history', 'javelin-workflow', 'javelin-mask', 'javelin-behavior-device', 'phabricator-keyboard-shortcut', ), '5b54c823' => array( 'javelin-install', 'javelin-stratcom', 'javelin-dom', 'javelin-util', ), '5cf0501a' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'phuix-dropdown-menu', ), '5d83623b' => array( 'javelin-dom', ), '5faf27b9' => array( 'phuix-form-control-view', ), + '60c3d405' => array( + 'phui-inline-comment-view-css', + ), '60cd9241' => array( 'javelin-behavior', ), '6337cf26' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', 'javelin-uri', ), '65bb0011' => array( 'javelin-behavior', 'javelin-dom', ), '66365ee2' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), '6648270a' => array( 'javelin-install', 'javelin-dom', ), + '68d963eb' => array( + 'javelin-dom', + 'javelin-util', + 'javelin-stratcom', + 'javelin-install', + 'javelin-workflow', + 'javelin-router', + 'javelin-behavior-device', + 'javelin-vector', + 'phabricator-diff-inline', + 'phabricator-diff-path-view', + 'phuix-button-view', + ), '6a1583a8' => array( 'javelin-behavior', 'javelin-history', ), '6a162524' => array( 'javelin-behavior', 'javelin-dom', ), '6a18c42e' => array( 'javelin-install', ), '6a30fa46' => array( 'phui-oi-list-view-css', ), '6a85bc5a' => array( 'javelin-behavior', 'javelin-dom', 'javelin-json', 'javelin-workflow', 'javelin-magical-init', ), '6c379000' => array( 'javelin-behavior', 'javelin-behavior-device', 'javelin-stratcom', 'javelin-vector', 'phui-hovercard', ), '6cfa0008' => array( 'javelin-dom', 'javelin-dynval', 'javelin-reactor', 'javelin-reactornode', 'javelin-install', 'javelin-util', ), - '6e668c5b' => array( - 'javelin-install', - 'phuix-button-view', - 'phabricator-diff-tree-view', - ), - '700bf848' => array( - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-install', - 'javelin-workflow', - 'javelin-router', - 'javelin-behavior-device', - 'javelin-vector', - 'phabricator-diff-inline', - 'phabricator-diff-path-view', - 'phuix-button-view', - ), 70245195 => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', ), '727a5a61' => array( 'phuix-icon-view', ), '72960bc1' => array( 'javelin-install', 'javelin-reactor', 'javelin-util', 'javelin-reactor-node-calmer', ), '73ecc1f8' => array( 'javelin-behavior', 'javelin-behavior-device', 'javelin-stratcom', 'phabricator-tooltip', ), '740956e1' => array( 'javelin-util', 'javelin-uri', 'javelin-install', ), 74446546 => array( 'javelin-behavior', 'javelin-dom', ), '75184d68' => array( 'javelin-behavior', 'javelin-dom', 'javelin-uri', 'javelin-request', ), '78bc5d94' => array( 'javelin-behavior', 'javelin-uri', 'phabricator-notification', ), '78f811c9' => array( 'javelin-install', ), '7930776a' => array( 'javelin-install', 'javelin-dom', ), - '7acfd98b' => array( - 'javelin-install', - 'javelin-util', - 'javelin-dom', - 'javelin-vector', - 'javelin-stratcom', - ), '7ad020a5' => array( 'javelin-behavior', 'javelin-dom', 'phabricator-drag-and-drop-file-upload', 'phabricator-textareautils', ), '7b139193' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', ), '7c4d8998' => array( 'javelin-install', 'javelin-dom', ), '80bff3af' => array( 'javelin-install', 'javelin-typeahead-source', ), '81debc48' => array( 'javelin-install', 'javelin-util', 'javelin-stratcom', 'javelin-dom', 'javelin-vector', ), '8207abf9' => array( 'javelin-dom', ), 83754533 => array( 'javelin-install', 'javelin-util', 'javelin-dom', 'javelin-vector', ), '84e6891f' => array( 'javelin-install', 'javelin-stratcom', 'javelin-util', 'javelin-behavior', 'javelin-json', 'javelin-dom', 'javelin-resource', 'javelin-routable', ), '84f82dad' => array( 'javelin-install', ), '87428eb2' => array( 'javelin-behavior', 'javelin-diffusion-locate-file-source', 'javelin-dom', 'javelin-typeahead', 'javelin-uri', ), '876506b6' => array( 'javelin-view', 'javelin-install', 'javelin-dom', ), '89a1ae3a' => array( 'javelin-dom', 'javelin-util', 'javelin-stratcom', 'javelin-install', ), '8ac32fd9' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', 'phabricator-draggable-list', ), '8b5c7d65' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'phabricator-busy', ), '8badee71' => array( 'javelin-install', 'javelin-util', 'javelin-dom', 'javelin-typeahead-normalizer', ), '8c2ed2bf' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'javelin-stratcom', 'javelin-workflow', 'javelin-behavior-device', 'javelin-history', 'javelin-vector', 'javelin-scrollbar', 'phabricator-title', 'phabricator-shaped-request', 'conpherence-thread-manager', ), '8e0aa661' => array( 'javelin-install', 'javelin-dom', ), '8f959ad0' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'javelin-workflow', 'javelin-stratcom', ), '91befbcc' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'javelin-workflow', 'javelin-stratcom', ), '92388bae' => array( 'javelin-behavior', 'javelin-scrollbar', ), '925fe8cd' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), '92cdd7b6' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), '9347f172' => array( 'javelin-behavior', 'multirow-row-manager', 'javelin-dom', 'javelin-util', 'phabricator-prefab', 'javelin-json', ), '94243d89' => array( 'javelin-install', 'javelin-dom', 'javelin-typeahead-preloaded-source', 'javelin-util', ), '945ff654' => array( 'javelin-stratcom', 'javelin-request', 'javelin-dom', 'javelin-vector', 'javelin-install', 'javelin-util', 'javelin-mask', 'javelin-uri', 'javelin-routable', ), '94681e22' => array( 'javelin-magical-init', 'javelin-install', 'javelin-util', 'javelin-vector', 'javelin-stratcom', ), '956f3eeb' => array( 'javelin-behavior', 'javelin-util', 'javelin-dom', 'javelin-stratcom', 'javelin-vector', ), '9623adc1' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', 'javelin-router', ), '98ef467f' => array( 'javelin-behavior', 'javelin-dom', 'javelin-request', 'javelin-util', ), '995f5102' => array( 'javelin-install', 'javelin-util', 'javelin-request', 'javelin-router', ), - '9a3963e0' => array( - 'javelin-dom', - ), '9aae2b66' => array( 'javelin-install', 'javelin-util', ), '9c01e364' => array( 'javelin-behavior', 'javelin-dom', 'javelin-workflow', ), '9cec214e' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', 'javelin-uri', 'phabricator-textareautils', ), '9f081f05' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', 'javelin-workflow', 'javelin-util', 'phabricator-keyboard-shortcut', ), 'a17b84f1' => array( 'javelin-behavior', 'javelin-dom', 'javelin-workflow', ), 'a241536a' => array( 'javelin-install', ), 'a2ab19be' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'javelin-stratcom', 'javelin-workflow', 'phabricator-draggable-list', ), 'a4356cde' => array( 'javelin-install', 'javelin-dom', 'javelin-vector', 'javelin-util', ), 'a43ae2ae' => array( 'javelin-install', 'javelin-dom', 'javelin-stratcom', 'javelin-vector', ), 'a4aa75c4' => array( 'phui-button-css', 'phui-button-simple-css', ), 'a5257c4e' => array( 'javelin-install', 'javelin-dom', ), - 'a5cc67cf' => array( - 'phui-inline-comment-view-css', - ), 'a77e2cbd' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'javelin-vector', ), 'a8f573a9' => array( 'javelin-install', 'javelin-dom', 'javelin-util', ), 'a9942052' => array( 'javelin-behavior', 'javelin-dom', 'javelin-view-renderer', 'javelin-install', ), 'a9b91e3f' => array( 'javelin-install', 'javelin-dom', 'javelin-stratcom', 'javelin-util', 'phabricator-notification-css', ), 'aa371860' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', 'phabricator-draggable-list', ), 'aa3a100c' => array( 'javelin-behavior', 'javelin-dom', 'javelin-typeahead', 'javelin-typeahead-ondemand-source', 'javelin-dom', ), 'aa6d2308' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'multirow-row-manager', 'javelin-json', 'phuix-form-control-view', ), 'ab85e184' => array( 'javelin-install', 'javelin-dom', 'phabricator-notification', ), + 'ac403c32' => array( + 'javelin-install', + 'phuix-button-view', + 'phabricator-diff-tree-view', + ), 'ad258e28' => array( 'javelin-behavior', 'javelin-dom', 'javelin-chart', ), 'ad486db3' => array( 'javelin-install', 'javelin-typeahead', 'javelin-dom', 'javelin-request', 'javelin-typeahead-ondemand-source', 'javelin-util', ), 'aec8e38c' => array( 'javelin-dom', 'javelin-util', 'javelin-stratcom', 'javelin-install', 'javelin-aphlict', 'javelin-workflow', 'javelin-router', 'javelin-behavior-device', 'javelin-vector', ), + 'b00168c1' => array( + 'javelin-dom', + ), 'b105a3a6' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), 'b26a41e4' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), 'b347a301' => array( 'javelin-behavior', ), 'b46d88c5' => array( 'javelin-install', 'javelin-dom', 'javelin-util', 'javelin-stratcom', 'javelin-workflow', 'phabricator-draggable-list', 'javelin-workboard-column', 'javelin-workboard-header-template', 'javelin-workboard-card-template', 'javelin-workboard-order-template', ), 'b49fd60c' => array( 'multirow-row-manager', 'trigger-rule', ), 'b517bfa0' => array( 'phui-oi-list-view-css', ), + 'b557770a' => array( + 'javelin-install', + 'javelin-util', + 'javelin-dom', + 'javelin-vector', + 'javelin-stratcom', + ), 'b58d1a2a' => array( 'javelin-behavior', 'javelin-behavior-device', 'javelin-stratcom', 'javelin-vector', 'javelin-dom', 'javelin-magical-init', ), 'b5e9bff9' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), 'b7b73831' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'phabricator-shaped-request', ), 'b86ef6c2' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', 'phabricator-tooltip', 'phabricator-diff-changeset-list', 'phabricator-diff-changeset', 'phuix-formation-view', ), 'b86f297f' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', 'phabricator-draggable-list', ), 'b9109f8f' => array( 'javelin-behavior', 'javelin-uri', 'phabricator-notification', ), 'b9d0c2f3' => array( 'javelin-install', 'javelin-dom', 'javelin-util', 'javelin-vector', 'javelin-stratcom', 'javelin-workflow', 'phabricator-drag-and-drop-file-upload', 'javelin-workboard-board', ), 'bcec20f0' => array( 'phui-theme-css', ), 'c03f2fb4' => array( 'javelin-install', ), 'c2c500a7' => array( 'javelin-install', 'javelin-dom', 'phuix-button-view', ), 'c3703a16' => array( 'javelin-behavior', 'javelin-aphlict', 'phabricator-phtize', 'javelin-dom', ), 'c3d24e63' => array( 'javelin-install', 'javelin-workboard-card', 'javelin-workboard-header', ), 'c687e867' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', 'javelin-workflow', 'javelin-fx', 'javelin-util', ), 'c68f183f' => array( 'javelin-install', 'javelin-dom', ), 'c715c123' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'javelin-workflow', 'javelin-json', ), 'c7e748bf' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'javelin-mask', 'javelin-util', 'phuix-icon-view', 'phabricator-busy', ), 'cef53b3e' => array( 'javelin-install', 'javelin-dom', 'phuix-formation-column-view', 'phuix-formation-flank-view', ), 'cf32921f' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', ), 'd12d214f' => array( 'javelin-install', 'javelin-dom', 'javelin-json', 'javelin-workflow', 'javelin-util', ), 'd3799cb4' => array( 'javelin-install', ), 'd4cc2d2a' => array( 'javelin-install', ), 'd8a86cfb' => array( 'javelin-behavior', 'javelin-dom', 'javelin-util', 'phabricator-shaped-request', ), 'da15d3dc' => array( 'phui-oi-list-view-css', ), + 'da8f5259' => array( + 'javelin-behavior', + 'javelin-dom', + ), 'dae2d55b' => array( 'javelin-behavior', 'javelin-uri', 'phabricator-notification', ), 'e150bd50' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'phuix-dropdown-menu', ), 'e5bdb730' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-workflow', 'javelin-dom', 'phabricator-draggable-list', ), 'e8240b50' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), 'e9a2940f' => array( 'javelin-behavior', 'javelin-request', 'javelin-stratcom', 'javelin-vector', 'javelin-dom', 'javelin-uri', 'javelin-behavior-device', 'phabricator-title', 'phabricator-favicon', ), 'e9c80beb' => array( 'javelin-install', 'javelin-event', ), 'ebe83a6b' => array( 'javelin-install', ), 'ec4e31c0' => array( 'phui-timeline-view-css', ), 'ee77366f' => array( 'aphront-dialog-view-css', ), 'ef836bf2' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', ), 'f340a484' => array( 'javelin-install', 'javelin-dom', 'javelin-vector', ), 'f84bcbf4' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', ), 'f8c4e135' => array( 'javelin-install', 'javelin-dom', 'javelin-view-visitor', 'javelin-util', ), 'fa6f30b2' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', 'javelin-behavior-device', 'javelin-scrollbar', 'javelin-quicksand', 'phabricator-keyboard-shortcut', 'conpherence-thread-manager', ), 'fa74cc35' => array( 'phui-oi-list-view-css', ), 'fdc13e4e' => array( 'javelin-install', ), 'ff688a7a' => array( 'owners-path-editor', 'javelin-behavior', ), - 'ff7b3f22' => array( - 'javelin-behavior', - 'javelin-dom', - ), ), 'packages' => array( 'conpherence.pkg.css' => array( 'conpherence-menu-css', 'conpherence-color-css', 'conpherence-message-pane-css', 'conpherence-notification-css', 'conpherence-transaction-css', 'conpherence-participant-pane-css', 'conpherence-header-pane-css', ), 'conpherence.pkg.js' => array( 'javelin-behavior-conpherence-menu', 'javelin-behavior-conpherence-participant-pane', 'javelin-behavior-conpherence-pontificate', 'javelin-behavior-toggle-widget', ), 'core.pkg.css' => array( 'phabricator-core-css', 'phabricator-zindex-css', 'phui-button-css', 'phui-button-simple-css', 'phui-theme-css', 'phabricator-standard-page-view', 'aphront-dialog-view-css', 'phui-form-view-css', 'aphront-panel-view-css', 'aphront-table-view-css', 'aphront-tokenizer-control-css', 'aphront-typeahead-control-css', 'aphront-list-filter-view-css', 'application-search-view-css', 'phabricator-remarkup-css', 'syntax-highlighting-css', 'syntax-default-css', 'phui-pager-css', 'aphront-tooltip-css', 'phabricator-flag-css', 'phui-info-view-css', 'phabricator-main-menu-view', 'phabricator-notification-css', 'phabricator-notification-menu-css', 'phui-lightbox-css', 'phui-comment-panel-css', 'phui-header-view-css', 'phabricator-nav-view-css', 'phui-basic-nav-view-css', 'phui-crumbs-view-css', 'phui-oi-list-view-css', 'phui-oi-color-css', 'phui-oi-big-ui-css', 'phui-oi-drag-ui-css', 'phui-oi-simple-ui-css', 'phui-oi-flush-ui-css', 'global-drag-and-drop-css', 'phui-spacing-css', 'phui-form-css', 'phui-icon-view-css', 'phabricator-action-list-view-css', 'phui-property-list-view-css', 'phui-tag-view-css', 'phui-list-view-css', 'font-fontawesome', 'font-lato', 'phui-font-icon-base-css', 'phui-fontkit-css', 'phui-box-css', 'phui-object-box-css', 'phui-timeline-view-css', 'phui-two-column-view-css', 'phui-curtain-view-css', 'sprite-login-css', 'sprite-tokens-css', 'tokens-css', 'auth-css', 'phui-status-list-view-css', 'phui-feed-story-css', 'phabricator-feed-css', 'phabricator-dashboard-css', 'aphront-multi-column-view-css', 'phui-curtain-object-ref-view-css', 'phui-comment-form-css', 'phui-head-thing-view-css', 'conpherence-durable-column-view', 'phui-button-bar-css', ), 'core.pkg.js' => array( 'javelin-util', 'javelin-install', 'javelin-event', 'javelin-stratcom', 'javelin-behavior', 'javelin-resource', 'javelin-request', 'javelin-vector', 'javelin-dom', 'javelin-json', 'javelin-uri', 'javelin-workflow', 'javelin-mask', 'javelin-typeahead', 'javelin-typeahead-normalizer', 'javelin-typeahead-source', 'javelin-typeahead-preloaded-source', 'javelin-typeahead-ondemand-source', 'javelin-tokenizer', 'javelin-history', 'javelin-router', 'javelin-routable', 'javelin-behavior-aphront-basic-tokenizer', 'javelin-behavior-workflow', 'javelin-behavior-aphront-form-disable-on-submit', 'phabricator-keyboard-shortcut-manager', 'phabricator-keyboard-shortcut', 'javelin-behavior-phabricator-keyboard-shortcuts', 'javelin-behavior-refresh-csrf', 'javelin-behavior-phabricator-watch-anchor', 'javelin-behavior-phabricator-autofocus', 'phuix-dropdown-menu', 'phuix-action-list-view', 'phuix-action-view', 'phuix-icon-view', 'phabricator-phtize', 'javelin-behavior-phabricator-oncopy', 'phabricator-tooltip', 'javelin-behavior-phabricator-tooltips', 'phabricator-prefab', 'javelin-behavior-device', 'javelin-behavior-toggle-class', 'javelin-behavior-lightbox-attachments', 'phabricator-busy', 'javelin-sound', 'javelin-aphlict', 'phabricator-notification', 'javelin-behavior-aphlict-listen', 'javelin-behavior-phabricator-search-typeahead', 'javelin-behavior-aphlict-dropdown', 'javelin-behavior-history-install', 'javelin-behavior-phabricator-gesture', 'javelin-behavior-phabricator-remarkup-assist', 'phabricator-textareautils', 'phabricator-file-upload', 'javelin-behavior-global-drag-and-drop', 'javelin-behavior-phabricator-reveal-content', 'phui-hovercard', 'javelin-behavior-phui-hovercards', 'javelin-color', 'javelin-fx', 'phabricator-draggable-list', 'javelin-behavior-phabricator-transaction-list', 'javelin-behavior-phabricator-show-older-transactions', 'javelin-behavior-phui-dropdown-menu', 'javelin-behavior-doorkeeper-tag', 'phabricator-title', 'javelin-leader', 'javelin-websocket', 'javelin-behavior-dashboard-async-panel', 'javelin-behavior-dashboard-tab-panel', 'javelin-quicksand', 'javelin-behavior-quicksand-blacklist', 'javelin-behavior-high-security-warning', 'javelin-behavior-read-only-warning', 'javelin-scrollbar', 'javelin-behavior-scrollbar', 'javelin-behavior-durable-column', 'conpherence-thread-manager', 'javelin-behavior-detect-timezone', 'javelin-behavior-setup-check-https', 'javelin-behavior-aphlict-status', 'javelin-behavior-user-menu', 'phabricator-favicon', 'javelin-behavior-phui-tab-group', 'javelin-behavior-phui-submenu', 'phuix-button-view', 'javelin-behavior-comment-actions', 'phuix-form-control-view', 'phuix-autocomplete', ), 'dark-console.pkg.js' => array( 'javelin-behavior-dark-console', 'phabricator-darklog', 'phabricator-darkmessage', ), 'differential.pkg.css' => array( 'differential-core-view-css', 'differential-changeset-view-css', 'differential-revision-history-css', 'differential-revision-list-css', 'differential-table-of-contents-css', 'differential-revision-comment-css', 'differential-revision-add-comment-css', 'phabricator-object-selector-css', 'phabricator-content-source-view-css', 'inline-comment-summary-css', 'phui-inline-comment-view-css', 'diff-tree-view-css', 'phui-formation-view-css', ), 'differential.pkg.js' => array( 'phabricator-drag-and-drop-file-upload', 'phabricator-shaped-request', 'javelin-behavior-differential-populate', 'javelin-behavior-differential-diff-radios', 'javelin-behavior-aphront-drag-and-drop-textarea', 'javelin-behavior-phabricator-object-selector', 'javelin-behavior-repository-crossreference', 'javelin-behavior-aphront-more', 'phabricator-diff-inline', 'phabricator-diff-changeset', 'phabricator-diff-changeset-list', 'phabricator-diff-tree-view', 'phabricator-diff-path-view', 'phuix-formation-view', 'phuix-formation-column-view', 'phuix-formation-flank-view', ), 'diffusion.pkg.css' => array( 'diffusion-icons-css', ), 'diffusion.pkg.js' => array( 'javelin-behavior-diffusion-pull-lastmodified', 'javelin-behavior-diffusion-commit-graph', 'javelin-behavior-audit-preview', ), 'maniphest.pkg.css' => array( 'maniphest-task-summary-css', ), 'maniphest.pkg.js' => array( 'javelin-behavior-maniphest-batch-selector', 'javelin-behavior-maniphest-list-editor', ), ), ); diff --git a/src/applications/almanac/util/AlmanacKeys.php b/src/applications/almanac/util/AlmanacKeys.php index 7ed9098e97..6ed0f187c1 100644 --- a/src/applications/almanac/util/AlmanacKeys.php +++ b/src/applications/almanac/util/AlmanacKeys.php @@ -1,77 +1,68 @@ getKey($cache_key); if (!$device) { $viewer = PhabricatorUser::getOmnipotentUser(); $device = id(new AlmanacDeviceQuery()) ->setViewer($viewer) ->withNames(array($device_id)) ->executeOne(); if (!$device) { throw new Exception( pht( 'This host has device ID "%s", but there is no corresponding '. 'device record in Almanac.', $device_id)); } $cache->setKey($cache_key, $device); } return $device; } public static function getClusterSSHUser() { - // NOTE: When instancing, we currently use the SSH username to figure out - // which instance you are connecting to. We can't use the host name because - // we have no way to tell which host you think you're reaching: the SSH - // protocol does not have a mechanism like a "Host" header. - $username = PhabricatorEnv::getEnvConfig('cluster.instance'); - if (strlen($username)) { - return $username; - } - $username = PhabricatorEnv::getEnvConfig('diffusion.ssh-user'); if (strlen($username)) { return $username; } return null; } } diff --git a/src/applications/auth/sshkey/PhabricatorAuthSSHPrivateKey.php b/src/applications/auth/sshkey/PhabricatorAuthSSHPrivateKey.php index ea601a3096..ad26da8ebb 100644 --- a/src/applications/auth/sshkey/PhabricatorAuthSSHPrivateKey.php +++ b/src/applications/auth/sshkey/PhabricatorAuthSSHPrivateKey.php @@ -1,210 +1,210 @@ } public function setPassphrase(PhutilOpaqueEnvelope $passphrase) { $this->passphrase = $passphrase; return $this; } public function getPassphrase() { return $this->passphrase; } public static function newFromRawKey(PhutilOpaqueEnvelope $entire_key) { $key = new self(); $key->body = $entire_key; return $key; } public function getKeyBody() { return $this->body; } public function newBarePrivateKey() { if (!Filesystem::binaryExists('ssh-keygen')) { throw new Exception( pht( 'Analyzing or decrypting SSH keys requires the "ssh-keygen" binary, '. 'but it is not available in "$PATH". Make it available to work with '. 'SSH private keys.')); } $old_body = $this->body; // Some versions of "ssh-keygen" are sensitive to trailing whitespace for // some keys. Trim any trailing whitespace and replace it with a single // newline. $raw_body = $old_body->openEnvelope(); $raw_body = rtrim($raw_body)."\n"; $old_body = new PhutilOpaqueEnvelope($raw_body); $tmp = $this->newTemporaryPrivateKeyFile($old_body); // See T13454 for discussion of why this is so awkward. In broad strokes, // we don't have a straightforward way to distinguish between keys with an // invalid format and keys with a passphrase which we don't know. // First, try to extract the public key from the file using the (possibly // empty) passphrase we were given. If everything is in good shape, this // should work. $passphrase = $this->getPassphrase(); if ($passphrase) { list($err, $stdout, $stderr) = exec_manual( 'ssh-keygen -y -P %P -f %R', $passphrase, $tmp); } else { list($err, $stdout, $stderr) = exec_manual( 'ssh-keygen -y -P %s -f %R', '', $tmp); } // If that worked, the key is good and the (possibly empty) passphrase is // correct. Strip the passphrase if we have one, then return the bare key. if (!$err) { if ($passphrase) { execx( - 'ssh-keygen -y -P %P -N %s -f %R', + 'ssh-keygen -p -P %P -N %s -f %R', $passphrase, '', $tmp); $new_body = new PhutilOpaqueEnvelope(Filesystem::readFile($tmp)); unset($tmp); } else { $new_body = $old_body; } return self::newFromRawKey($new_body); } // We were not able to extract the public key. Try to figure out why. The // reasons we expect are: // // - We were given a passphrase, but the key has no passphrase. // - We were given a passphrase, but the passphrase is wrong. // - We were not given a passphrase, but the key has a passphrase. // - The key format is invalid. // // Our ability to separate these cases varies a lot, particularly because // some versions of "ssh-keygen" return very similar diagnostic messages // for any error condition. Try our best. if ($passphrase) { // First, test for "we were given a passphrase, but the key has no // passphrase", since this is a conclusive test. list($err) = exec_manual( 'ssh-keygen -y -P %s -f %R', '', $tmp); if (!$err) { throw new PhabricatorAuthSSHPrivateKeySurplusPassphraseException( pht( 'A passphrase was provided for this private key, but it does '. 'not require a passphrase. Check that you supplied the correct '. 'key, or omit the passphrase.')); } } // We're out of conclusive tests, so try to guess why the error occurred. // In some versions of "ssh-keygen", we get a usable diagnostic message. In // other versions, not so much. $reason_format = 'format'; $reason_passphrase = 'passphrase'; $reason_unknown = 'unknown'; $patterns = array( // macOS 10.14.6 '/incorrect passphrase supplied to decrypt private key/' => $reason_passphrase, // macOS 10.14.6 '/invalid format/' => $reason_format, // Ubuntu 14 '/load failed/' => $reason_unknown, ); $reason = 'unknown'; foreach ($patterns as $pattern => $pattern_reason) { $ok = preg_match($pattern, $stderr); if ($ok === false) { throw new Exception( pht( 'Pattern "%s" is not valid.', $pattern)); } if ($ok) { $reason = $pattern_reason; break; } } if ($reason === $reason_format) { throw new PhabricatorAuthSSHPrivateKeyFormatException( pht( 'This private key is not formatted correctly. Check that you '. 'have provided the complete text of a valid private key.')); } if ($reason === $reason_passphrase) { if ($passphrase) { throw new PhabricatorAuthSSHPrivateKeyIncorrectPassphraseException( pht( 'This private key requires a passphrase, but the wrong '. 'passphrase was provided. Check that you supplied the correct '. 'key and passphrase.')); } else { throw new PhabricatorAuthSSHPrivateKeyIncorrectPassphraseException( pht( 'This private key requires a passphrase, but no passphrase was '. 'provided. Check that you supplied the correct key, or provide '. 'the passphrase.')); } } if ($passphrase) { throw new PhabricatorAuthSSHPrivateKeyUnknownException( pht( 'This private key could not be opened with the provided passphrase. '. 'This might mean that the passphrase is wrong or that the key is '. 'not formatted correctly. Check that you have supplied the '. 'complete text of a valid private key and the correct passphrase.')); } else { throw new PhabricatorAuthSSHPrivateKeyUnknownException( pht( 'This private key could not be opened. This might mean that the '. 'key requires a passphrase, or might mean that the key is not '. 'formatted correctly. Check that you have supplied the complete '. 'text of a valid private key and the correct passphrase.')); } } private function newTemporaryPrivateKeyFile(PhutilOpaqueEnvelope $key_body) { $tmp = new TempFile(); Filesystem::writeFile($tmp, $key_body->openEnvelope()); return $tmp; } } diff --git a/src/applications/celerity/postprocessor/CelerityDefaultPostprocessor.php b/src/applications/celerity/postprocessor/CelerityDefaultPostprocessor.php index 5430eb49cc..be039772c1 100644 --- a/src/applications/celerity/postprocessor/CelerityDefaultPostprocessor.php +++ b/src/applications/celerity/postprocessor/CelerityDefaultPostprocessor.php @@ -1,249 +1,253 @@ "13px 'Segoe UI', 'Segoe UI Emoji', ". "'Segoe UI Symbol', 'Lato', 'Helvetica Neue', ". "Helvetica, Arial, sans-serif", 'fontfamily' => "'Segoe UI', 'Segoe UI Emoji', ". "'Segoe UI Symbol', 'Lato', 'Helvetica Neue', ". "Helvetica, Arial, sans-serif", // Drop Shadow 'dropshadow' => '0 2px 12px rgba(0, 0, 0, .20)', 'whitetextshadow' => '0 1px 0 rgba(255, 255, 255, 1)', // Anchors 'anchor' => '#136CB2', // Font Sizes 'biggestfontsize' => '15px', 'biggerfontsize' => '14px', 'normalfontsize' => '13px', 'smallerfontsize' => '12px', 'smallestfontsize' => '11px', // Base Colors 'red' => '#c0392b', 'lightred' => '#f4dddb', 'orange' => '#e67e22', 'lightorange' => '#f7e2d4', 'yellow' => '#f1c40f', 'lightyellow' => '#fdf5d4', 'green' => '#139543', 'lightgreen' => '#d7eddf', 'blue' => '#2980b9', 'lightblue' => '#daeaf3', 'sky' => '#3498db', 'lightsky' => '#ddeef9', 'fire' => '#e62f17', 'indigo' => '#6e5cb6', 'lightindigo' => '#eae6f7', 'pink' => '#da49be', 'lightpink' => '#fbeaf8', 'violet' => '#8e44ad', 'lightviolet' => '#ecdff1', 'charcoal' => '#4b4d51', 'backdrop' => '#c4cde0', 'hoverwhite' => 'rgba(255,255,255,.6)', 'hovergrey' => '#c5cbcf', 'hoverblue' => '#eceff5', 'hoverborder' => '#dfe1e9', 'hoverselectedgrey' => '#bbc4ca', 'hoverselectedblue' => '#e6e9ee', 'borderinset' => 'inset 0 0 0 1px rgba(55,55,55,.15)', 'timeline' => '#d5d8e1', 'timeline.icon.background' => '#E6E9F1', 'bluepropertybackground' => '#eff3fc', // Alphas 'alphawhite' => '255,255,255', 'alphagrey' => '55,55,55', 'alphablue' => '71,87,120', 'alphablack' => '0,0,0', // Base Greys + 'thingreyborder' => '#dadee8', 'lightgreyborder' => '#C7CCD9', 'greyborder' => '#A1A6B0', 'darkgreyborder' => '#676A70', 'lightgreytext' => '#92969D', 'greytext' => '#74777D', 'darkgreytext' => '#4B4D51', 'lightgreybackground' => '#F7F7F7', 'greybackground' => '#EBECEE', 'darkgreybackground' => '#DFE0E2', // Base Blues 'thinblueborder' => '#DDE8EF', 'lightblueborder' => '#BFCFDA', 'blueborder' => '#8C98B8', 'darkblueborder' => '#626E82', 'lightbluebackground' => '#F8F9FC', 'bluebackground' => '#ECEEF4', 'lightbluetext' => '#8C98B8', 'bluetext' => '#6B748C', 'darkbluetext' => '#464C5C', 'blacktext' => '#000', // Base Greens 'lightgreenborder' => '#bfdac1', 'greenborder' => '#8cb89c', 'greentext' => '#3e6d35', 'lightgreenbackground' => '#e6f2e4', // Base Red 'lightredborder' => '#f4c6c6', 'redborder' => '#eb9797', 'redtext' => '#802b2b', 'lightredbackground' => '#f5e1e1', // Base Violet 'lightvioletborder' => '#cfbddb', 'violetborder' => '#b589ba', 'violettext' => '#603c73', 'lightvioletbackground' => '#e9dfee', // Shades are a more muted set of our base colors // better suited to blending into other UIs. // Shade Red 'sh-lightredborder' => '#efcfcf', 'sh-redborder' => '#d1abab', 'sh-redicon' => '#c85a5a', 'sh-redtext' => '#a53737', 'sh-redbackground' => '#f7e6e6', // Shade Orange 'sh-lightorangeborder' => '#f8dcc3', 'sh-orangeborder' => '#dbb99e', 'sh-orangeicon' => '#e78331', 'sh-orangetext' => '#ba6016', 'sh-orangebackground' => '#fbede1', // Shade Yellow 'sh-lightyellowborder' => '#e9dbcd', 'sh-yellowborder' => '#c9b8a8', 'sh-yellowicon' => '#9b946e', 'sh-yellowtext' => '#726f56', 'sh-yellowbackground' => '#fdf3da', // Shade Green 'sh-lightgreenborder' => '#c6e6c7', 'sh-greenborder' => '#a0c4a1', 'sh-greenicon' => '#4ca74e', 'sh-greentext' => '#326d34', 'sh-greenbackground' => '#ddefdd', // Shade Blue 'sh-lightblueborder' => '#cfdbe3', 'sh-blueborder' => '#a7b5bf', 'sh-blueicon' => '#6b748c', 'sh-bluetext' => '#464c5c', 'sh-bluebackground' => '#dee7f8', // Shade Indigo 'sh-lightindigoborder' => '#d1c9ee', 'sh-indigoborder' => '#bcb4da', 'sh-indigoicon' => '#8672d4', 'sh-indigotext' => '#6e5cb6', 'sh-indigobackground' => '#eae6f7', // Shade Violet 'sh-lightvioletborder' => '#e0d1e7', 'sh-violetborder' => '#bcabc5', 'sh-violeticon' => '#9260ad', 'sh-violettext' => '#69427f', 'sh-violetbackground' => '#efe8f3', // Shade Pink 'sh-lightpinkborder' => '#f6d5ef', 'sh-pinkborder' => '#d5aecd', 'sh-pinkicon' => '#e26fcb', 'sh-pinktext' => '#da49be', 'sh-pinkbackground' => '#fbeaf8', // Shade Grey 'sh-lightgreyborder' => '#e3e4e8', 'sh-greyborder' => '#b2b2b2', 'sh-greyicon' => '#757575', 'sh-greytext' => '#555555', 'sh-greybackground' => '#edeef2', // Shade Disabled 'sh-lightdisabledborder' => '#e5e5e5', 'sh-disabledborder' => '#cbcbcb', 'sh-disabledicon' => '#bababa', 'sh-disabledtext' => '#a6a6a6', 'sh-disabledbackground' => '#f3f3f3', // Diffs 'diff.background' => '#fff', 'new-background' => 'rgba(151, 234, 151, .3)', 'new-bright' => 'rgba(151, 234, 151, .6)', 'old-background' => 'rgba(251, 175, 175, .3)', 'old-bright' => 'rgba(251, 175, 175, .7)', 'move-background' => '#fdf5d4', 'copy-background' => '#f1c40f', // Usually light yellow 'gentle.highlight' => '#fdf3da', 'gentle.highlight.border' => '#c9b8a8', + 'gentle.highlight.background' => '#fffdf6', + + 'highlight.bright' => '#fdf320', 'paste.content' => '#fffef5', 'paste.border' => '#e9dbcd', 'paste.highlight' => '#fdf3da', // Background color for "most" themes. 'page.background' => '#f3f5f7', 'page.sidenav' => '#eaedf1', 'page.content' => '#fff', 'menu.profile.text' => 'rgba(255,255,255,.8)', 'menu.profile.text.selected' => 'rgba(255,255,255,1)', 'menu.profile.icon.disabled' => 'rgba(255,255,255,.4)', 'menu.main.height' => '44px', 'menu.profile.width' => '240px', // Buttons 'blue.button.color' => '#2980b9', 'blue.button.gradient' => 'linear-gradient(to bottom, #3498db, #2980b9)', 'blue.button.hover' => 'linear-gradient(to bottom, #3498db, #1b6ba0)', 'green.button.color' => '#139543', 'green.button.gradient' => 'linear-gradient(to bottom, #23BB5B, #139543)', 'green.button.hover' => 'linear-gradient(to bottom, #23BB5B, #178841)', 'red.button.color' => '#b33225', 'red.button.gradient' => 'linear-gradient(to bottom, #d25454, #b33225)', 'red.button.hover' => 'linear-gradient(to bottom, #d25454, #982115)', 'grey.button.color' => '#F7F7F9', 'grey.button.gradient' => 'linear-gradient(to bottom, #ffffff, #f1f0f1)', 'grey.button.hover' => 'linear-gradient(to bottom, #ffffff, #eeebec)', 'document.border' => '#dedee1', 'delete-color' => '#c0392b', 'create-color' => '#139543', ); } } diff --git a/src/applications/differential/mail/DifferentialInlineCommentMailView.php b/src/applications/differential/mail/DifferentialInlineCommentMailView.php index c511d81a65..44c6161174 100644 --- a/src/applications/differential/mail/DifferentialInlineCommentMailView.php +++ b/src/applications/differential/mail/DifferentialInlineCommentMailView.php @@ -1,497 +1,519 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setInlines($inlines) { $this->inlines = $inlines; return $this; } public function getInlines() { return $this->inlines; } public function buildMailSection() { $inlines = $this->getInlines(); $comments = mpull($inlines, 'getComment'); $comments = mpull($comments, null, 'getPHID'); $parents = $this->loadParents($comments); $all_comments = $comments + $parents; $this->changesets = $this->loadChangesets($all_comments); $this->authors = $this->loadAuthors($all_comments); $groups = $this->groupInlines($inlines); $hunk_parser = new DifferentialHunkParser(); $spacer_text = null; $spacer_html = phutil_tag('br'); $section = new PhabricatorMetaMTAMailSection(); $last_group_key = last_key($groups); foreach ($groups as $changeset_id => $group) { $changeset = $this->getChangeset($changeset_id); if (!$changeset) { continue; } $is_last_group = ($changeset_id == $last_group_key); $last_inline_key = last_key($group); foreach ($group as $inline_key => $inline) { $comment = $inline->getComment(); $parent_phid = $comment->getReplyToCommentPHID(); + $inline_object = $comment->newInlineCommentObject(); + $document_engine_key = $inline_object->getDocumentEngineKey(); + $is_last_inline = ($inline_key == $last_inline_key); $context_text = null; $context_html = null; if ($parent_phid) { $parent = idx($parents, $parent_phid); if ($parent) { $context_text = $this->renderInline($parent, false, true); $context_html = $this->renderInline($parent, true, true); } + } else if ($document_engine_key !== null) { + // See T13513. If an inline was left on a rendered document, don't + // include the patch context. Document engines currently can not + // render to mail targets, and using the line numbers as raw source + // lines produces misleading context. + + $patch_text = null; + $context_text = $this->renderPatch($comment, $patch_text, false); + + $patch_html = null; + $context_html = $this->renderPatch($comment, $patch_html, true); } else { $patch_text = $this->getPatch($hunk_parser, $comment, false); $context_text = $this->renderPatch($comment, $patch_text, false); $patch_html = $this->getPatch($hunk_parser, $comment, true); $context_html = $this->renderPatch($comment, $patch_html, true); } $render_text = $this->renderInline($comment, false, false); $render_html = $this->renderInline($comment, true, false); $section->addPlaintextFragment($context_text); $section->addPlaintextFragment($spacer_text); $section->addPlaintextFragment($render_text); $html_fragment = $this->renderContentBox( array( $context_html, $render_html, )); $section->addHTMLFragment($html_fragment); if (!$is_last_group || !$is_last_inline) { $section->addPlaintextFragment($spacer_text); $section->addHTMLFragment($spacer_html); } } } return $section; } private function loadChangesets(array $comments) { if (!$comments) { return array(); } $ids = array(); foreach ($comments as $comment) { $ids[] = $comment->getChangesetID(); } $changesets = id(new DifferentialChangesetQuery()) ->setViewer($this->getViewer()) ->withIDs($ids) ->needHunks(true) ->execute(); return mpull($changesets, null, 'getID'); } private function loadParents(array $comments) { $viewer = $this->getViewer(); $phids = array(); foreach ($comments as $comment) { $parent_phid = $comment->getReplyToCommentPHID(); if (!$parent_phid) { continue; } $phids[] = $parent_phid; } if (!$phids) { return array(); } $parents = id(new DifferentialDiffInlineCommentQuery()) ->setViewer($viewer) ->withPHIDs($phids) ->execute(); return mpull($parents, null, 'getPHID'); } private function loadAuthors(array $comments) { $viewer = $this->getViewer(); $phids = array(); foreach ($comments as $comment) { $author_phid = $comment->getAuthorPHID(); if (!$author_phid) { continue; } $phids[] = $author_phid; } if (!$phids) { return array(); } return $viewer->loadHandles($phids); } private function groupInlines(array $inlines) { return DifferentialTransactionComment::sortAndGroupInlines( $inlines, $this->changesets); } private function renderInline( DifferentialTransactionComment $comment, $is_html, $is_quote) { $changeset = $this->getChangeset($comment->getChangesetID()); if (!$changeset) { return null; } $content = $comment->getContent(); $content = $this->renderRemarkupContent($content, $is_html); if ($is_quote) { $header = $this->renderHeader($comment, $is_html, true); } else { $header = null; } if ($is_html) { $style = array( 'margin: 8px 0;', 'padding: 0 12px;', ); if ($is_quote) { $style[] = 'color: #74777D;'; } $content = phutil_tag( 'div', array( 'style' => implode(' ', $style), ), $content); } $parts = array( $header, "\n", $content, ); if (!$is_html) { $parts = implode('', $parts); $parts = trim($parts); } if ($is_quote) { if ($is_html) { $parts = $this->quoteHTML($parts); } else { $parts = $this->quoteText($parts); } } return $parts; } private function renderRemarkupContent($content, $is_html) { $viewer = $this->getViewer(); $production_uri = PhabricatorEnv::getProductionURI('/'); if ($is_html) { $mode = PhutilRemarkupEngine::MODE_HTML_MAIL; } else { $mode = PhutilRemarkupEngine::MODE_TEXT; } $attributes = array( 'style' => 'padding: 0; margin: 8px;', ); $engine = PhabricatorMarkupEngine::newMarkupEngine(array()) ->setConfig('viewer', $viewer) ->setConfig('uri.base', $production_uri) ->setConfig('default.p.attributes', $attributes) ->setMode($mode); try { return $engine->markupText($content); } catch (Exception $ex) { return $content; } } private function getChangeset($id) { return idx($this->changesets, $id); } private function getAuthor($phid) { if (isset($this->authors[$phid])) { return $this->authors[$phid]; } return null; } private function quoteText($block) { $block = phutil_split_lines($block); foreach ($block as $key => $line) { $block[$key] = '> '.$line; } return implode('', $block); } private function quoteHTML($block) { $styles = array( 'padding: 0;', 'background: #F7F7F7;', 'border-color: #e3e4e8;', 'border-style: solid;', 'border-width: 0 0 1px 0;', 'margin: 0;', ); $styles = implode(' ', $styles); return phutil_tag( 'div', array( 'style' => $styles, ), $block); } private function getPatch( DifferentialHunkParser $parser, DifferentialTransactionComment $comment, $is_html) { $changeset = $this->getChangeset($comment->getChangesetID()); $is_new = $comment->getIsNewFile(); $start = $comment->getLineNumber(); $length = $comment->getLineLength(); // By default, show one line of context around the target inline. $context = 1; // If the inline is at least 3 lines long, don't show any extra context. if ($length >= 2) { $context = 0; } // If the inline is more than 7 lines long, only show the first 7 lines. if ($length >= 6) { $length = 6; } if (!$is_html) { $hunks = $changeset->getHunks(); $patch = $parser->makeContextDiff( $hunks, $is_new, $start, $length, $context); $patch = phutil_split_lines($patch); // Remove the "@@ -x,y +u,v @@" line. array_shift($patch); return implode('', $patch); } $viewer = $this->getViewer(); $engine = new PhabricatorMarkupEngine(); if ($is_new) { $offset_mode = 'new'; } else { $offset_mode = 'old'; } // See PHI894. Use the parse cache since we can end up with a large // rendering cost otherwise when users or bots leave hundreds of inline // comments on diffs with long recipient lists. $cache_key = $changeset->getID(); $viewstate = new PhabricatorChangesetViewState(); $parser = id(new DifferentialChangesetParser()) ->setRenderCacheKey($cache_key) ->setViewer($viewer) ->setViewstate($viewstate) ->setChangeset($changeset) ->setOffsetMode($offset_mode) ->setMarkupEngine($engine); $parser->setRenderer(new DifferentialChangesetOneUpMailRenderer()); return $parser->render( $start - $context, $length + (2 * $context), array()); } private function renderPatch( DifferentialTransactionComment $comment, $patch, $is_html) { if ($is_html) { - $patch = $this->renderCodeBlock($patch); + if ($patch !== null) { + $patch = $this->renderCodeBlock($patch); + } } $header = $this->renderHeader($comment, $is_html, false); - $patch = array( - $header, - "\n", - $patch, - ); + if ($patch === null) { + $patch = array( + $header, + ); + } else { + $patch = array( + $header, + "\n", + $patch, + ); + } if (!$is_html) { $patch = implode('', $patch); $patch = $this->quoteText($patch); } else { $patch = $this->quoteHTML($patch); } return $patch; } private function renderHeader( DifferentialTransactionComment $comment, $is_html, $with_author) { $changeset = $this->getChangeset($comment->getChangesetID()); $path = $changeset->getFilename(); // Only show the filename. $path = basename($path); $start = $comment->getLineNumber(); $length = $comment->getLineLength(); if ($length) { $range = pht('%s-%s', $start, $start + $length); } else { $range = $start; } $header = "{$path}:{$range}"; if ($is_html) { $header = $this->renderHeaderBold($header); } if ($with_author) { $author = $this->getAuthor($comment->getAuthorPHID()); } else { $author = null; } if ($author) { $byline = $author->getName(); if ($is_html) { $byline = $this->renderHeaderBold($byline); } $header = pht('%s wrote in %s', $byline, $header); } if ($is_html) { $link_href = $this->getInlineURI($comment); if ($link_href) { $link_style = array( 'float: right;', 'text-decoration: none;', ); $link = phutil_tag( 'a', array( 'style' => implode(' ', $link_style), 'href' => $link_href, ), array( pht('View Inline'), // See PHI920. Add a space after the link so we render this into // the document: // // View Inline filename.txt // // Otherwise, we render "Inlinefilename.txt" and double-clicking // the file name selects the word "Inline" as well. ' ', )); } else { $link = null; } $header = $this->renderHeaderBlock(array($link, $header)); } return $header; } private function getInlineURI(DifferentialTransactionComment $comment) { $changeset = $this->getChangeset($comment->getChangesetID()); if (!$changeset) { return null; } $diff = $changeset->getDiff(); if (!$diff) { return null; } $revision = $diff->getRevision(); if (!$revision) { return null; } $link_href = '/'.$revision->getMonogram().'#inline-'.$comment->getID(); $link_href = PhabricatorEnv::getProductionURI($link_href); return $link_href; } } diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php index 102e5b9ea2..d9ebad0663 100644 --- a/src/applications/differential/parser/DifferentialChangesetParser.php +++ b/src/applications/differential/parser/DifferentialChangesetParser.php @@ -1,1908 +1,1954 @@ rangeStart = $start; $this->rangeEnd = $end; return $this; } public function setMask(array $mask) { $this->mask = $mask; return $this; } public function renderChangeset() { return $this->render($this->rangeStart, $this->rangeEnd, $this->mask); } public function setShowEditAndReplyLinks($bool) { $this->showEditAndReplyLinks = $bool; return $this; } public function getShowEditAndReplyLinks() { return $this->showEditAndReplyLinks; } public function setViewState(PhabricatorChangesetViewState $view_state) { $this->viewState = $view_state; return $this; } public function getViewState() { return $this->viewState; } public function setRenderer(DifferentialChangesetRenderer $renderer) { $this->renderer = $renderer; return $this; } public function getRenderer() { return $this->renderer; } public function setDisableCache($disable_cache) { $this->disableCache = $disable_cache; return $this; } public function getDisableCache() { return $this->disableCache; } public function setCanMarkDone($can_mark_done) { $this->canMarkDone = $can_mark_done; return $this; } public function getCanMarkDone() { return $this->canMarkDone; } public function setObjectOwnerPHID($phid) { $this->objectOwnerPHID = $phid; return $this; } public function getObjectOwnerPHID() { return $this->objectOwnerPHID; } public function setOffsetMode($offset_mode) { $this->offsetMode = $offset_mode; return $this; } public function getOffsetMode() { return $this->offsetMode; } public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } private function newRenderer() { $viewer = $this->getViewer(); $viewstate = $this->getViewstate(); $renderer_key = $viewstate->getRendererKey(); if ($renderer_key === null) { $is_unified = $viewer->compareUserSetting( PhabricatorUnifiedDiffsSetting::SETTINGKEY, PhabricatorUnifiedDiffsSetting::VALUE_ALWAYS_UNIFIED); if ($is_unified) { $renderer_key = '1up'; } else { $renderer_key = $viewstate->getDefaultDeviceRendererKey(); } } switch ($renderer_key) { case '1up': $renderer = new DifferentialChangesetOneUpRenderer(); break; default: $renderer = new DifferentialChangesetTwoUpRenderer(); break; } return $renderer; } const CACHE_VERSION = 14; const CACHE_MAX_SIZE = 8e6; const ATTR_GENERATED = 'attr:generated'; const ATTR_DELETED = 'attr:deleted'; const ATTR_UNCHANGED = 'attr:unchanged'; const ATTR_MOVEAWAY = 'attr:moveaway'; public function setOldLines(array $lines) { $this->old = $lines; return $this; } public function setNewLines(array $lines) { $this->new = $lines; return $this; } public function setSpecialAttributes(array $attributes) { $this->specialAttributes = $attributes; return $this; } public function setIntraLineDiffs(array $diffs) { $this->intra = $diffs; return $this; } public function setDepthOnlyLines(array $lines) { $this->depthOnlyLines = $lines; return $this; } public function getDepthOnlyLines() { return $this->depthOnlyLines; } public function setVisibleLinesMask(array $mask) { $this->visible = $mask; return $this; } public function setLinesOfContext($lines_of_context) { $this->linesOfContext = $lines_of_context; return $this; } public function getLinesOfContext() { return $this->linesOfContext; } /** * Configure which Changeset comments added to the right side of the visible * diff will be attached to. The ID must be the ID of a real Differential * Changeset. * * The complexity here is that we may show an arbitrary side of an arbitrary * changeset as either the left or right part of a diff. This method allows * the left and right halves of the displayed diff to be correctly mapped to * storage changesets. * * @param id The Differential Changeset ID that comments added to the right * side of the visible diff should be attached to. * @param bool If true, attach new comments to the right side of the storage * changeset. Note that this may be false, if the left side of * some storage changeset is being shown as the right side of * a display diff. * @return this */ public function setRightSideCommentMapping($id, $is_new) { $this->rightSideChangesetID = $id; $this->rightSideAttachesToNewFile = $is_new; return $this; } /** * See setRightSideCommentMapping(), but this sets information for the left * side of the display diff. */ public function setLeftSideCommentMapping($id, $is_new) { $this->leftSideChangesetID = $id; $this->leftSideAttachesToNewFile = $is_new; return $this; } public function setOriginals( DifferentialChangeset $left, DifferentialChangeset $right) { $this->originalLeft = $left; $this->originalRight = $right; return $this; } public function diffOriginals() { $engine = new PhabricatorDifferenceEngine(); $changeset = $engine->generateChangesetFromFileContent( implode('', mpull($this->originalLeft->getHunks(), 'getChanges')), implode('', mpull($this->originalRight->getHunks(), 'getChanges'))); $parser = new DifferentialHunkParser(); return $parser->parseHunksForHighlightMasks( $changeset->getHunks(), $this->originalLeft->getHunks(), $this->originalRight->getHunks()); } /** * Set a key for identifying this changeset in the render cache. If set, the * parser will attempt to use the changeset render cache, which can improve * performance for frequently-viewed changesets. * * By default, there is no render cache key and parsers do not use the cache. * This is appropriate for rarely-viewed changesets. * * NOTE: Currently, this key must be a valid Differential Changeset ID. * * @param string Key for identifying this changeset in the render cache. * @return this */ public function setRenderCacheKey($key) { $this->renderCacheKey = $key; return $this; } private function getRenderCacheKey() { return $this->renderCacheKey; } public function setChangeset(DifferentialChangeset $changeset) { $this->changeset = $changeset; $this->setFilename($changeset->getFilename()); return $this; } public function setRenderingReference($ref) { $this->renderingReference = $ref; return $this; } private function getRenderingReference() { return $this->renderingReference; } public function getChangeset() { return $this->changeset; } public function setFilename($filename) { $this->filename = $filename; return $this; } public function setHandles(array $handles) { assert_instances_of($handles, 'PhabricatorObjectHandle'); $this->handles = $handles; return $this; } public function setMarkupEngine(PhabricatorMarkupEngine $engine) { $this->markupEngine = $engine; return $this; } public function setCoverage($coverage) { $this->coverage = $coverage; return $this; } private function getCoverage() { return $this->coverage; } public function parseInlineComment( PhabricatorInlineComment $comment) { // Parse only comments which are actually visible. if ($this->isCommentVisibleOnRenderedDiff($comment)) { $this->comments[] = $comment; } return $this; } private function loadCache() { $render_cache_key = $this->getRenderCacheKey(); if (!$render_cache_key) { return false; } $data = null; $changeset = new DifferentialChangeset(); $conn_r = $changeset->establishConnection('r'); $data = queryfx_one( $conn_r, 'SELECT * FROM %T WHERE id = %d', $changeset->getTableName().'_parse_cache', $render_cache_key); if (!$data) { return false; } if ($data['cache'][0] == '{') { // This is likely an old-style JSON cache which we will not be able to // deserialize. return false; } $data = unserialize($data['cache']); if (!is_array($data) || !$data) { return false; } foreach (self::getCacheableProperties() as $cache_key) { if (!array_key_exists($cache_key, $data)) { // If we're missing a cache key, assume we're looking at an old cache // and ignore it. return false; } } if ($data['cacheVersion'] !== self::CACHE_VERSION) { return false; } // Someone displays contents of a partially cached shielded file. if (!isset($data['newRender']) && (!$this->isTopLevel || $this->comments)) { return false; } unset($data['cacheVersion'], $data['cacheHost']); $cache_prop = array_select_keys($data, self::getCacheableProperties()); foreach ($cache_prop as $cache_key => $v) { $this->$cache_key = $v; } return true; } protected static function getCacheableProperties() { return array( 'visible', 'new', 'old', 'intra', 'depthOnlyLines', 'newRender', 'oldRender', 'specialAttributes', 'hunkStartLines', 'cacheVersion', 'cacheHost', 'highlightingDisabled', ); } public function saveCache() { if (PhabricatorEnv::isReadOnly()) { return false; } if ($this->highlightErrors) { return false; } $render_cache_key = $this->getRenderCacheKey(); if (!$render_cache_key) { return false; } $cache = array(); foreach (self::getCacheableProperties() as $cache_key) { switch ($cache_key) { case 'cacheVersion': $cache[$cache_key] = self::CACHE_VERSION; break; case 'cacheHost': $cache[$cache_key] = php_uname('n'); break; default: $cache[$cache_key] = $this->$cache_key; break; } } $cache = serialize($cache); // We don't want to waste too much space by a single changeset. if (strlen($cache) > self::CACHE_MAX_SIZE) { return; } $changeset = new DifferentialChangeset(); $conn_w = $changeset->establishConnection('w'); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); try { queryfx( $conn_w, 'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %B, %d) ON DUPLICATE KEY UPDATE cache = VALUES(cache)', DifferentialChangeset::TABLE_CACHE, $render_cache_key, $cache, time()); } catch (AphrontQueryException $ex) { // Ignore these exceptions. A common cause is that the cache is // larger than 'max_allowed_packet', in which case we're better off // not writing it. // TODO: It would be nice to tailor this more narrowly. } unset($unguarded); } private function markGenerated($new_corpus_block = '') { $generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false); if (!$generated_guess) { $generated_path_regexps = PhabricatorEnv::getEnvConfig( 'differential.generated-paths'); foreach ($generated_path_regexps as $regexp) { if (preg_match($regexp, $this->changeset->getFilename())) { $generated_guess = true; break; } } } $event = new PhabricatorEvent( PhabricatorEventType::TYPE_DIFFERENTIAL_WILLMARKGENERATED, array( 'corpus' => $new_corpus_block, 'is_generated' => $generated_guess, ) ); PhutilEventEngine::dispatchEvent($event); $generated = $event->getValue('is_generated'); $attribute = $this->changeset->isGeneratedChangeset(); if ($attribute) { $generated = true; } $this->specialAttributes[self::ATTR_GENERATED] = $generated; } public function isGenerated() { return idx($this->specialAttributes, self::ATTR_GENERATED, false); } public function isDeleted() { return idx($this->specialAttributes, self::ATTR_DELETED, false); } public function isUnchanged() { return idx($this->specialAttributes, self::ATTR_UNCHANGED, false); } public function isMoveAway() { return idx($this->specialAttributes, self::ATTR_MOVEAWAY, false); } private function applyIntraline(&$render, $intra, $corpus) { foreach ($render as $key => $text) { $result = $text; if (isset($intra[$key])) { $result = PhabricatorDifferenceEngine::applyIntralineDiff( $result, $intra[$key]); } $result = $this->adjustRenderedLineForDisplay($result); $render[$key] = $result; } } private function getHighlightFuture($corpus) { $language = $this->getViewState()->getHighlightLanguage(); if (!$language) { $language = $this->highlightEngine->getLanguageFromFilename( $this->filename); if (($language != 'txt') && (strlen($corpus) > self::HIGHLIGHT_BYTE_LIMIT)) { $this->highlightingDisabled = true; $language = 'txt'; } } return $this->highlightEngine->getHighlightFuture( $language, $corpus); } protected function processHighlightedSource($data, $result) { $result_lines = phutil_split_lines($result); foreach ($data as $key => $info) { if (!$info) { unset($result_lines[$key]); } } return $result_lines; } private function tryCacheStuff() { $changeset = $this->getChangeset(); if (!$changeset->hasSourceTextBody()) { // TODO: This isn't really correct (the change is not "generated"), the // intent is just to not render a text body for Subversion directory // changes, etc. $this->markGenerated(); return; } $viewstate = $this->getViewState(); $skip_cache = false; if ($this->disableCache) { $skip_cache = true; } $character_encoding = $viewstate->getCharacterEncoding(); if ($character_encoding !== null) { $skip_cache = true; } $highlight_language = $viewstate->getHighlightLanguage(); if ($highlight_language !== null) { $skip_cache = true; } if ($skip_cache || !$this->loadCache()) { $this->process(); if (!$skip_cache) { $this->saveCache(); } } } private function process() { $changeset = $this->changeset; $hunk_parser = new DifferentialHunkParser(); $hunk_parser->parseHunksForLineData($changeset->getHunks()); $this->realignDiff($changeset, $hunk_parser); $hunk_parser->reparseHunksForSpecialAttributes(); $unchanged = false; if (!$hunk_parser->getHasAnyChanges()) { $filetype = $this->changeset->getFileType(); if ($filetype == DifferentialChangeType::FILE_TEXT || $filetype == DifferentialChangeType::FILE_SYMLINK) { $unchanged = true; } } $moveaway = false; $changetype = $this->changeset->getChangeType(); if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) { $moveaway = true; } $this->setSpecialAttributes(array( self::ATTR_UNCHANGED => $unchanged, self::ATTR_DELETED => $hunk_parser->getIsDeleted(), self::ATTR_MOVEAWAY => $moveaway, )); $lines_context = $this->getLinesOfContext(); $hunk_parser->generateIntraLineDiffs(); $hunk_parser->generateVisibleLinesMask($lines_context); $this->setOldLines($hunk_parser->getOldLines()); $this->setNewLines($hunk_parser->getNewLines()); $this->setIntraLineDiffs($hunk_parser->getIntraLineDiffs()); $this->setDepthOnlyLines($hunk_parser->getDepthOnlyLines()); $this->setVisibleLinesMask($hunk_parser->getVisibleLinesMask()); $this->hunkStartLines = $hunk_parser->getHunkStartLines( $changeset->getHunks()); $new_corpus = $hunk_parser->getNewCorpus(); $new_corpus_block = implode('', $new_corpus); $this->markGenerated($new_corpus_block); if ($this->isTopLevel && !$this->comments && ($this->isGenerated() || $this->isUnchanged() || $this->isDeleted())) { return; } $old_corpus = $hunk_parser->getOldCorpus(); $old_corpus_block = implode('', $old_corpus); $old_future = $this->getHighlightFuture($old_corpus_block); $new_future = $this->getHighlightFuture($new_corpus_block); $futures = array( 'old' => $old_future, 'new' => $new_future, ); $corpus_blocks = array( 'old' => $old_corpus_block, 'new' => $new_corpus_block, ); $this->highlightErrors = false; foreach (new FutureIterator($futures) as $key => $future) { try { try { $highlighted = $future->resolve(); } catch (PhutilSyntaxHighlighterException $ex) { $this->highlightErrors = true; $highlighted = id(new PhutilDefaultSyntaxHighlighter()) ->getHighlightFuture($corpus_blocks[$key]) ->resolve(); } switch ($key) { case 'old': $this->oldRender = $this->processHighlightedSource( $this->old, $highlighted); break; case 'new': $this->newRender = $this->processHighlightedSource( $this->new, $highlighted); break; } } catch (Exception $ex) { phlog($ex); throw $ex; } } $this->applyIntraline( $this->oldRender, ipull($this->intra, 0), $old_corpus); $this->applyIntraline( $this->newRender, ipull($this->intra, 1), $new_corpus); } private function shouldRenderPropertyChangeHeader($changeset) { if (!$this->isTopLevel) { // We render properties only at top level; otherwise we get multiple // copies of them when a user clicks "Show More". return false; } return true; } public function render( $range_start = null, $range_len = null, $mask_force = array()) { $viewer = $this->getViewer(); $renderer = $this->getRenderer(); if (!$renderer) { $renderer = $this->newRenderer(); $this->setRenderer($renderer); } // "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(); $viewstate = $this->getViewState(); $encoding = null; $character_encoding = $viewstate->getCharacterEncoding(); if ($character_encoding) { // We are forcing this changeset to be interpreted with a specific // character encoding, so force all the hunks into that encoding and // propagate it to the renderer. $encoding = $character_encoding; foreach ($this->changeset->getHunks() as $hunk) { $hunk->forceEncoding($character_encoding); } } else { // We're just using the default, so tell the renderer what that is // (by reading the encoding from the first hunk). foreach ($this->changeset->getHunks() as $hunk) { $encoding = $hunk->getDataEncoding(); break; } } $this->tryCacheStuff(); // If we're rendering in an offset mode, treat the range numbers as line // numbers instead of rendering offsets. $offset_mode = $this->getOffsetMode(); if ($offset_mode) { if ($offset_mode == 'new') { $offset_map = $this->new; } else { $offset_map = $this->old; } // NOTE: Inline comments use zero-based lengths. For example, a comment // that starts and ends on line 123 has length 0. Rendering considers // this range to have length 1. Probably both should agree, but that // ship likely sailed long ago. Tweak things here to get the two systems // to agree. See PHI985, where this affected mail rendering of inline // comments left on the final line of a file. $range_end = $this->getOffset($offset_map, $range_start + $range_len); $range_start = $this->getOffset($offset_map, $range_start); $range_len = ($range_end - $range_start) + 1; } $render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset); $rows = max( count($this->old), count($this->new)); $renderer = $this->getRenderer() ->setUser($this->getViewer()) ->setChangeset($this->changeset) ->setRenderPropertyChangeHeader($render_pch) ->setIsTopLevel($this->isTopLevel) ->setOldRender($this->oldRender) ->setNewRender($this->newRender) ->setHunkStartLines($this->hunkStartLines) ->setOldChangesetID($this->leftSideChangesetID) ->setNewChangesetID($this->rightSideChangesetID) ->setOldAttachesToNewFile($this->leftSideAttachesToNewFile) ->setNewAttachesToNewFile($this->rightSideAttachesToNewFile) ->setCodeCoverage($this->getCoverage()) ->setRenderingReference($this->getRenderingReference()) ->setMarkupEngine($this->markupEngine) ->setHandles($this->handles) ->setOldLines($this->old) ->setNewLines($this->new) ->setOriginalCharacterEncoding($encoding) ->setShowEditAndReplyLinks($this->getShowEditAndReplyLinks()) ->setCanMarkDone($this->getCanMarkDone()) ->setObjectOwnerPHID($this->getObjectOwnerPHID()) ->setHighlightingDisabled($this->highlightingDisabled) ->setDepthOnlyLines($this->getDepthOnlyLines()); list($engine, $old_ref, $new_ref) = $this->newDocumentEngine(); if ($engine) { $engine_blocks = $engine->newEngineBlocks( $old_ref, $new_ref); } else { $engine_blocks = null; } $has_document_engine = ($engine_blocks !== null); // Remove empty comments that don't have any unsaved draft data. PhabricatorInlineComment::loadAndAttachVersionedDrafts( $viewer, $this->comments); foreach ($this->comments as $key => $comment) { if ($comment->isVoidComment($viewer)) { unset($this->comments[$key]); } } // See T13515. Sometimes, we collapse file content by default: for // example, if the file is marked as containing generated code. // If a file has inline comments, that normally means we never collapse // it. However, if the viewer has already collapsed all of the inlines, // it's fine to collapse the file. $expanded_comments = array(); foreach ($this->comments as $comment) { if ($comment->isHidden()) { continue; } $expanded_comments[] = $comment; } $collapsed_count = (count($this->comments) - count($expanded_comments)); $shield_raw = null; $shield_text = null; $shield_type = null; if ($this->isTopLevel && !$expanded_comments && !$has_document_engine) { if ($this->isGenerated()) { $shield_text = pht( 'This file contains generated code, which does not normally '. 'need to be reviewed.'); } else if ($this->isMoveAway()) { // We put an empty shield on these files. Normally, they do not have // any diff content anyway. However, if they come through `arc`, they // may have content. We don't want to show it (it's not useful) and // we bailed out of fully processing it earlier anyway. // We could show a message like "this file was moved", but we show // that as a change header anyway, so it would be redundant. Instead, // just render an empty shield to skip rendering the diff body. $shield_raw = ''; } else if ($this->isUnchanged()) { $type = 'text'; if (!$rows) { // NOTE: Normally, diffs which don't change files do not include // file content (for example, if you "chmod +x" a file and then // run "git show", the file content is not available). Similarly, // if you move a file from A to B without changing it, diffs normally // do not show the file content. In some cases `arc` is able to // synthetically generate content for these diffs, but for raw diffs // we'll never have it so we need to be prepared to not render a link. $type = 'none'; } $shield_type = $type; $type_add = DifferentialChangeType::TYPE_ADD; if ($this->changeset->getChangeType() == $type_add) { // Although the generic message is sort of accurate in a technical // sense, this more-tailored message is less confusing. $shield_text = pht('This is an empty file.'); } else { $shield_text = pht('The contents of this file were not changed.'); } } else if ($this->isDeleted()) { $shield_text = pht('This file was completely deleted.'); } else if ($this->changeset->getAffectedLineCount() > 2500) { $shield_text = pht( 'This file has a very large number of changes (%s lines).', new PhutilNumber($this->changeset->getAffectedLineCount())); } } $shield = null; if ($shield_raw !== null) { $shield = $shield_raw; } else if ($shield_text !== null) { if ($shield_type === null) { $shield_type = 'default'; } // If we have inlines and the shield would normally show the whole file, // downgrade it to show only text around the inlines. if ($collapsed_count) { if ($shield_type === 'text') { $shield_type = 'default'; } $shield_text = array( $shield_text, ' ', pht( 'This file has %d collapsed inline comment(s).', new PhutilNumber($collapsed_count)), ); } $shield = $renderer->renderShield($shield_text, $shield_type); } if ($shield !== null) { return $renderer->renderChangesetTable($shield); } // This request should render the "undershield" headers if it's a top-level // request which made it this far (indicating the changeset has no shield) // or it's a request with no mask information (indicating it's the request // that removes the rendering shield). Possibly, this second class of // request might need to be made more explicit. $is_undershield = (empty($mask_force) || $this->isTopLevel); $renderer->setIsUndershield($is_undershield); $old_comments = array(); $new_comments = array(); $old_mask = array(); $new_mask = array(); $feedback_mask = array(); $lines_context = $this->getLinesOfContext(); if ($this->comments) { // If there are any comments which appear in sections of the file which // we don't have, we're going to move them backwards to the closest // earlier line. Two cases where this may happen are: // // - Porting ghost comments forward into a file which was mostly // deleted. // - Porting ghost comments forward from a full-context diff to a // partial-context diff. list($old_backmap, $new_backmap) = $this->buildLineBackmaps(); foreach ($this->comments as $comment) { $new_side = $this->isCommentOnRightSideWhenDisplayed($comment); $line = $comment->getLineNumber(); // See T13524. Lint inlines from Harbormaster may not have a line // number. if ($line === null) { $back_line = null; } else if ($new_side) { $back_line = idx($new_backmap, $line); } else { $back_line = idx($old_backmap, $line); } if ($back_line != $line) { // TODO: This should probably be cleaner, but just be simple and // obvious for now. $ghost = $comment->getIsGhost(); if ($ghost) { $moved = pht( 'This comment originally appeared on line %s, but that line '. 'does not exist in this version of the diff. It has been '. 'moved backward to the nearest line.', new PhutilNumber($line)); $ghost['reason'] = $ghost['reason']."\n\n".$moved; $comment->setIsGhost($ghost); } $comment->setLineNumber($back_line); $comment->setLineLength(0); } $start = max($comment->getLineNumber() - $lines_context, 0); $end = $comment->getLineNumber() + $comment->getLineLength() + $lines_context; for ($ii = $start; $ii <= $end; $ii++) { if ($new_side) { $new_mask[$ii] = true; } else { $old_mask[$ii] = true; } } } foreach ($this->old as $ii => $old) { if (isset($old['line']) && isset($old_mask[$old['line']])) { $feedback_mask[$ii] = true; } } foreach ($this->new as $ii => $new) { if (isset($new['line']) && isset($new_mask[$new['line']])) { $feedback_mask[$ii] = true; } } $this->comments = id(new PHUIDiffInlineThreader()) ->reorderAndThreadCommments($this->comments); foreach ($this->comments as $comment) { $final = $comment->getLineNumber() + $comment->getLineLength(); $final = max(1, $final); if ($this->isCommentOnRightSideWhenDisplayed($comment)) { $new_comments[$final][] = $comment; } else { $old_comments[$final][] = $comment; } } } $renderer ->setOldComments($old_comments) ->setNewComments($new_comments); if ($engine_blocks !== null) { $reference = $this->getRenderingReference(); $parts = explode('/', $reference); if (count($parts) == 2) { list($id, $vs) = $parts; } else { $id = $parts[0]; $vs = 0; } // If we don't have an explicit "vs" changeset, it's the left side of // the "id" changeset. if (!$vs) { $vs = $id; } + if ($mask_force) { + $engine_blocks->setRevealedIndexes(array_keys($mask_force)); + } + + if ($range_start !== null || $range_len !== null) { + $range_min = $range_start; + + if ($range_len === null) { + $range_max = null; + } else { + $range_max = (int)$range_start + (int)$range_len; + } + + $engine_blocks->setRange($range_min, $range_max); + } + $renderer ->setDocumentEngine($engine) ->setDocumentEngineBlocks($engine_blocks); return $renderer->renderDocumentEngineBlocks( $engine_blocks, (string)$id, (string)$vs); } // If we've made it here with a type of file we don't know how to render, // bail out with a default empty rendering. Normally, we'd expect a // document engine to catch these changes before we make it this far. switch ($this->changeset->getFileType()) { case DifferentialChangeType::FILE_DIRECTORY: case DifferentialChangeType::FILE_BINARY: case DifferentialChangeType::FILE_IMAGE: $output = $renderer->renderChangesetTable(null); return $output; } if ($this->originalLeft && $this->originalRight) { list($highlight_old, $highlight_new) = $this->diffOriginals(); $highlight_old = array_flip($highlight_old); $highlight_new = array_flip($highlight_new); $renderer ->setHighlightOld($highlight_old) ->setHighlightNew($highlight_new); } $renderer ->setOriginalOld($this->originalLeft) ->setOriginalNew($this->originalRight); if ($range_start === null) { $range_start = 0; } if ($range_len === null) { $range_len = $rows; } $range_len = min($range_len, $rows - $range_start); list($gaps, $mask) = $this->calculateGapsAndMask( $mask_force, $feedback_mask, $range_start, $range_len); $renderer ->setGaps($gaps) ->setMask($mask); $html = $renderer->renderTextChange( $range_start, $range_len, $rows); return $renderer->renderChangesetTable($html); } /** * This function calculates a lot of stuff we need to know to display * the diff: * * Gaps - compute gaps in the visible display diff, where we will render * "Show more context" spacers. If a gap is smaller than the context size, * we just display it. Otherwise, we record it into $gaps and will render a * "show more context" element instead of diff text below. A given $gap * is a tuple of $gap_line_number_start and $gap_length. * * Mask - compute the actual lines that need to be shown (because they * are near changes lines, near inline comments, or the request has * explicitly asked for them, i.e. resulting from the user clicking * "show more"). The $mask returned is a sparsely populated dictionary * of $visible_line_number => true. * * @return array($gaps, $mask) */ private function calculateGapsAndMask( $mask_force, $feedback_mask, $range_start, $range_len) { $lines_context = $this->getLinesOfContext(); $gaps = array(); $gap_start = 0; $in_gap = false; $base_mask = $this->visible + $mask_force + $feedback_mask; $base_mask[$range_start + $range_len] = true; for ($ii = $range_start; $ii <= $range_start + $range_len; $ii++) { if (isset($base_mask[$ii])) { if ($in_gap) { $gap_length = $ii - $gap_start; if ($gap_length <= $lines_context) { for ($jj = $gap_start; $jj <= $gap_start + $gap_length; $jj++) { $base_mask[$jj] = true; } } else { $gaps[] = array($gap_start, $gap_length); } $in_gap = false; } } else { if (!$in_gap) { $gap_start = $ii; $in_gap = true; } } } $gaps = array_reverse($gaps); $mask = $base_mask; return array($gaps, $mask); } /** * Determine if an inline comment will appear on the rendered diff, * taking into consideration which halves of which changesets will actually * be shown. * * @param PhabricatorInlineComment Comment to test for visibility. * @return bool True if the comment is visible on the rendered diff. */ private function isCommentVisibleOnRenderedDiff( PhabricatorInlineComment $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 PhabricatorInlineComment Comment to test for display * location. * @return bool True for right, false for left. */ private function isCommentOnRightSideWhenDisplayed( PhabricatorInlineComment $comment) { if (!$this->isCommentVisibleOnRenderedDiff($comment)) { throw new Exception(pht('Comment is not visible on changeset!')); } $changeset_id = $comment->getChangesetID(); $is_new = $comment->getIsNewFile(); if ($changeset_id == $this->rightSideChangesetID && $is_new == $this->rightSideAttachesToNewFile) { return true; } return false; } /** * Parse the 'range' specification that this class and the client-side JS * emit to indicate that a user clicked "Show more..." on a diff. Generally, * use is something like this: * * $spec = $request->getStr('range'); * $parsed = DifferentialChangesetParser::parseRangeSpecification($spec); * list($start, $end, $mask) = $parsed; * $parser->render($start, $end, $mask); * * @param string Range specification, indicating the range of the diff that * should be rendered. * @return tuple List of suitable for passing to * @{method:render}. */ public static function parseRangeSpecification($spec) { $range_s = null; $range_e = null; $mask = array(); if ($spec) { $match = null; if (preg_match('@^(\d+)-(\d+)(?:/(\d+)-(\d+))?$@', $spec, $match)) { $range_s = (int)$match[1]; $range_e = (int)$match[2]; if (count($match) > 3) { $start = (int)$match[3]; $len = (int)$match[4]; for ($ii = $start; $ii < $start + $len; $ii++) { $mask[$ii] = true; } } } } return array($range_s, $range_e, $mask); } /** * Render "modified coverage" information; test coverage on modified lines. * This synthesizes diff information with unit test information into a useful * indicator of how well tested a change is. */ public function renderModifiedCoverage() { $na = phutil_tag('em', array(), '-'); $coverage = $this->getCoverage(); if (!$coverage) { return $na; } $covered = 0; $not_covered = 0; foreach ($this->new as $k => $new) { if (!$new['line']) { continue; } if (!$new['type']) { continue; } if (empty($coverage[$new['line'] - 1])) { continue; } switch ($coverage[$new['line'] - 1]) { case 'C': $covered++; break; case 'U': $not_covered++; break; } } if (!$covered && !$not_covered) { return $na; } return sprintf('%d%%', 100 * ($covered / ($covered + $not_covered))); } /** * Build maps from lines comments appear on to actual lines. */ private function buildLineBackmaps() { $old_back = array(); $new_back = array(); foreach ($this->old as $ii => $old) { if ($old === null) { continue; } $old_back[$old['line']] = $old['line']; } foreach ($this->new as $ii => $new) { if ($new === null) { continue; } $new_back[$new['line']] = $new['line']; } $max_old_line = 0; $max_new_line = 0; foreach ($this->comments as $comment) { if ($this->isCommentOnRightSideWhenDisplayed($comment)) { $max_new_line = max($max_new_line, $comment->getLineNumber()); } else { $max_old_line = max($max_old_line, $comment->getLineNumber()); } } $cursor = 1; for ($ii = 1; $ii <= $max_old_line; $ii++) { if (empty($old_back[$ii])) { $old_back[$ii] = $cursor; } else { $cursor = $old_back[$ii]; } } $cursor = 1; for ($ii = 1; $ii <= $max_new_line; $ii++) { if (empty($new_back[$ii])) { $new_back[$ii] = $cursor; } else { $cursor = $new_back[$ii]; } } return array($old_back, $new_back); } private function getOffset(array $map, $line) { if (!$map) { return null; } $line = (int)$line; foreach ($map as $key => $spec) { if ($spec && isset($spec['line'])) { if ((int)$spec['line'] >= $line) { return $key; } } } return $key; } private function realignDiff( DifferentialChangeset $changeset, DifferentialHunkParser $hunk_parser) { // Normalizing and realigning the diff depends on rediffing the files, and // we currently need complete representations of both files to do anything // reasonable. If we only have parts of the files, skip realignment. // We have more than one hunk, so we're definitely missing part of the file. $hunks = $changeset->getHunks(); if (count($hunks) !== 1) { return null; } // The first hunk doesn't start at the beginning of the file, so we're // missing some context. $first_hunk = head($hunks); if ($first_hunk->getOldOffset() != 1 || $first_hunk->getNewOffset() != 1) { return null; } $old_file = $changeset->makeOldFile(); $new_file = $changeset->makeNewFile(); if ($old_file === $new_file) { // If the old and new files are exactly identical, the synthetic // diff below will give us nonsense and whitespace modes are // irrelevant anyway. This occurs when you, e.g., copy a file onto // itself in Subversion (see T271). return null; } $engine = id(new PhabricatorDifferenceEngine()) ->setNormalize(true); $normalized_changeset = $engine->generateChangesetFromFileContent( $old_file, $new_file); $type_parser = new DifferentialHunkParser(); $type_parser->parseHunksForLineData($normalized_changeset->getHunks()); $hunk_parser->setNormalized(true); $hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap()); $hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap()); } private function adjustRenderedLineForDisplay($line) { // IMPORTANT: We're using "str_replace()" against raw HTML here, which can // easily become unsafe. The input HTML has already had syntax highlighting // and intraline diff highlighting applied, so it's full of "" tags. static $search; static $replace; if ($search === null) { $rules = $this->newSuspiciousCharacterRules(); $map = array(); foreach ($rules as $key => $spec) { $tag = phutil_tag( 'span', array( 'data-copy-text' => $key, 'class' => $spec['class'], 'title' => $spec['title'], ), $spec['replacement']); $map[$key] = phutil_string_cast($tag); } $search = array_keys($map); $replace = array_values($map); } $is_html = false; if ($line instanceof PhutilSafeHTML) { $is_html = true; $line = hsprintf('%s', $line); } $line = phutil_string_cast($line); // TODO: This should be flexible, eventually. $tab_width = 2; $line = self::replaceTabsWithSpaces($line, $tab_width); $line = str_replace($search, $replace, $line); if ($is_html) { $line = phutil_safe_html($line); } return $line; } private function newSuspiciousCharacterRules() { // The "title" attributes are cached in the database, so they're // intentionally not wrapped in "pht(...)". $rules = array( "\xE2\x80\x8B" => array( 'title' => 'ZWS', 'class' => 'suspicious-character', 'replacement' => '!', ), "\xC2\xA0" => array( 'title' => 'NBSP', 'class' => 'suspicious-character', 'replacement' => '!', ), "\x7F" => array( 'title' => 'DEL (0x7F)', 'class' => 'suspicious-character', 'replacement' => "\xE2\x90\xA1", ), ); // Unicode defines special pictures for the control characters in the // range between "0x00" and "0x1F". $control = array( 'NULL', 'SOH', 'STX', 'ETX', 'EOT', 'ENQ', 'ACK', 'BEL', 'BS', null, // "\t" Tab null, // "\n" New Line 'VT', 'FF', null, // "\r" Carriage Return, 'SO', 'SI', 'DLE', 'DC1', 'DC2', 'DC3', 'DC4', 'NAK', 'SYN', 'ETB', 'CAN', 'EM', 'SUB', 'ESC', 'FS', 'GS', 'RS', 'US', ); foreach ($control as $idx => $label) { if ($label === null) { continue; } $rules[chr($idx)] = array( 'title' => sprintf('%s (0x%02X)', $label, $idx), 'class' => 'suspicious-character', 'replacement' => "\xE2\x90".chr(0x80 + $idx), ); } return $rules; } public static function replaceTabsWithSpaces($line, $tab_width) { static $tags = array(); if (empty($tags[$tab_width])) { for ($ii = 1; $ii <= $tab_width; $ii++) { $tag = phutil_tag( 'span', array( 'data-copy-text' => "\t", ), str_repeat(' ', $ii)); $tag = phutil_string_cast($tag); $tags[$ii] = $tag; } } // Expand all prefix tabs until we encounter any non-tab character. This // is cheap and often immediately produces the correct result with no // further work (and, particularly, no need to handle any unicode cases). $len = strlen($line); $head = 0; for ($head = 0; $head < $len; $head++) { $char = $line[$head]; if ($char !== "\t") { break; } } if ($head) { if (empty($tags[$tab_width * $head])) { $tags[$tab_width * $head] = str_repeat($tags[$tab_width], $head); } $prefix = $tags[$tab_width * $head]; $line = substr($line, $head); } else { $prefix = ''; } // If we have no remaining tabs elsewhere in the string after taking care // of all the prefix tabs, we're done. if (strpos($line, "\t") === false) { return $prefix.$line; } $len = strlen($line); // If the line is particularly long, don't try to do anything special with // it. Use a faster approximation of the correct tabstop expansion instead. // This usually still arrives at the right result. if ($len > 256) { return $prefix.str_replace("\t", $tags[$tab_width], $line); } $in_tag = false; $pos = 0; // See PHI1210. If the line only has single-byte characters, we don't need // to vectorize it and can avoid an expensive UTF8 call. $fast_path = preg_match('/^[\x01-\x7F]*\z/', $line); if ($fast_path) { $replace = array(); for ($ii = 0; $ii < $len; $ii++) { $char = $line[$ii]; if ($char === '>') { $in_tag = false; continue; } if ($in_tag) { continue; } if ($char === '<') { $in_tag = true; continue; } if ($char === "\t") { $count = $tab_width - ($pos % $tab_width); $pos += $count; $replace[$ii] = $tags[$count]; continue; } $pos++; } if ($replace) { // Apply replacements starting at the end of the string so they // don't mess up the offsets for following replacements. $replace = array_reverse($replace, true); foreach ($replace as $replace_pos => $replacement) { $line = substr_replace($line, $replacement, $replace_pos, 1); } } } else { $line = phutil_utf8v_combined($line); foreach ($line as $key => $char) { if ($char === '>') { $in_tag = false; continue; } if ($in_tag) { continue; } if ($char === '<') { $in_tag = true; continue; } if ($char === "\t") { $count = $tab_width - ($pos % $tab_width); $pos += $count; $line[$key] = $tags[$count]; continue; } $pos++; } $line = implode('', $line); } return $prefix.$line; } private function newDocumentEngine() { $changeset = $this->changeset; $viewer = $this->getViewer(); list($old_file, $new_file) = $this->loadFileObjectsForChangeset(); $no_old = !$changeset->hasOldState(); $no_new = !$changeset->hasNewState(); if ($no_old) { $old_ref = null; } else { $old_ref = id(new PhabricatorDocumentRef()) ->setName($changeset->getOldFile()); if ($old_file) { $old_ref->setFile($old_file); } else { $old_data = $this->getRawDocumentEngineData($this->old); $old_ref->setData($old_data); } } if ($no_new) { $new_ref = null; } else { $new_ref = id(new PhabricatorDocumentRef()) ->setName($changeset->getFilename()); if ($new_file) { $new_ref->setFile($new_file); } else { $new_data = $this->getRawDocumentEngineData($this->new); $new_ref->setData($new_data); } } $old_engines = null; if ($old_ref) { $old_engines = PhabricatorDocumentEngine::getEnginesForRef( $viewer, $old_ref); } $new_engines = null; if ($new_ref) { $new_engines = PhabricatorDocumentEngine::getEnginesForRef( $viewer, $new_ref); } if ($new_engines !== null && $old_engines !== null) { $shared_engines = array_intersect_key($new_engines, $old_engines); $default_engine = head_key($new_engines); } else if ($new_engines !== null) { $shared_engines = $new_engines; $default_engine = head_key($shared_engines); } else if ($old_engines !== null) { $shared_engines = $old_engines; $default_engine = head_key($shared_engines); } else { return null; } foreach ($shared_engines as $key => $shared_engine) { if (!$shared_engine->canDiffDocuments($old_ref, $new_ref)) { unset($shared_engines[$key]); } } + $this->availableDocumentEngines = $shared_engines; + $viewstate = $this->getViewState(); $engine_key = $viewstate->getDocumentEngineKey(); if (strlen($engine_key)) { if (isset($shared_engines[$engine_key])) { $document_engine = $shared_engines[$engine_key]; } else { $document_engine = null; } } else { // If we aren't rendering with a specific engine, only use a default // engine if the best engine for the new file is a shared engine which // can diff files. If we're less picky (for example, by accepting any // shared engine) we can end up with silly behavior (like ".json" files // rendering as Jupyter documents). if (isset($shared_engines[$default_engine])) { $document_engine = $shared_engines[$default_engine]; } else { $document_engine = null; } } if ($document_engine) { return array( $document_engine, $old_ref, $new_ref); } return null; } private function loadFileObjectsForChangeset() { $changeset = $this->changeset; $viewer = $this->getViewer(); $old_phid = $changeset->getOldFileObjectPHID(); $new_phid = $changeset->getNewFileObjectPHID(); $old_file = null; $new_file = null; if ($old_phid || $new_phid) { $file_phids = array(); if ($old_phid) { $file_phids[] = $old_phid; } if ($new_phid) { $file_phids[] = $new_phid; } $files = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs($file_phids) ->execute(); $files = mpull($files, null, 'getPHID'); if ($old_phid) { $old_file = idx($files, $old_phid); if (!$old_file) { throw new Exception( pht( 'Failed to load file data for changeset ("%s").', $old_phid)); } $changeset->attachOldFileObject($old_file); } if ($new_phid) { $new_file = idx($files, $new_phid); if (!$new_file) { throw new Exception( pht( 'Failed to load file data for changeset ("%s").', $new_phid)); } $changeset->attachNewFileObject($new_file); } } return array($old_file, $new_file); } public function newChangesetResponse() { // NOTE: This has to happen first because it has side effects. Yuck. $rendered_changeset = $this->renderChangeset(); $renderer = $this->getRenderer(); $renderer_key = $renderer->getRendererKey(); $viewstate = $this->getViewState(); $undo_templates = $renderer->renderUndoTemplates(); foreach ($undo_templates as $key => $undo_template) { $undo_templates[$key] = hsprintf('%s', $undo_template); } + $document_engine = $renderer->getDocumentEngine(); + if ($document_engine) { + $document_engine_key = $document_engine->getDocumentEngineKey(); + } else { + $document_engine_key = null; + } + + $available_keys = array(); + $engines = $this->availableDocumentEngines; + if (!$engines) { + $engines = array(); + } + + $available_keys = mpull($engines, 'getDocumentEngineKey'); + + // TODO: Always include "source" as a usable engine to default to + // the buitin rendering. This is kind of a hack and does not actually + // use the source engine. The source engine isn't a diff engine, so + // selecting it causes us to fall through and render with builtin + // behavior. For now, overall behavir is reasonable. + + $available_keys[] = PhabricatorSourceDocumentEngine::ENGINEKEY; + $available_keys = array_fuse($available_keys); + $available_keys = array_values($available_keys); + $state = array( 'undoTemplates' => $undo_templates, 'rendererKey' => $renderer_key, 'highlight' => $viewstate->getHighlightLanguage(), 'characterEncoding' => $viewstate->getCharacterEncoding(), - 'documentEngine' => $viewstate->getDocumentEngineKey(), + 'requestDocumentEngineKey' => $viewstate->getDocumentEngineKey(), + 'responseDocumentEngineKey' => $document_engine_key, + 'availableDocumentEngineKeys' => $available_keys, 'isHidden' => $viewstate->getHidden(), ); return id(new PhabricatorChangesetResponse()) ->setRenderedChangeset($rendered_changeset) ->setChangesetState($state); } private function getRawDocumentEngineData(array $lines) { $text = array(); foreach ($lines as $line) { if ($line === null) { continue; } // If this is a "No newline at end of file." annotation, don't hand it // off to the DocumentEngine. if ($line['type'] === '\\') { continue; } $text[] = $line['text']; } return implode('', $text); } } diff --git a/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php b/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php index 56fc3135e5..7612f9e876 100644 --- a/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php @@ -1,620 +1,649 @@ getChangeset(); $change = $changeset->getChangeType(); $file = $changeset->getFileType(); $messages = array(); switch ($change) { case DifferentialChangeType::TYPE_ADD: switch ($file) { case DifferentialChangeType::FILE_TEXT: $messages[] = pht('This file was added.'); break; case DifferentialChangeType::FILE_IMAGE: $messages[] = pht('This image was added.'); break; case DifferentialChangeType::FILE_DIRECTORY: $messages[] = pht('This directory was added.'); break; case DifferentialChangeType::FILE_BINARY: $messages[] = pht('This binary file was added.'); break; case DifferentialChangeType::FILE_SYMLINK: $messages[] = pht('This symlink was added.'); break; case DifferentialChangeType::FILE_SUBMODULE: $messages[] = pht('This submodule was added.'); break; } break; case DifferentialChangeType::TYPE_DELETE: switch ($file) { case DifferentialChangeType::FILE_TEXT: $messages[] = pht('This file was deleted.'); break; case DifferentialChangeType::FILE_IMAGE: $messages[] = pht('This image was deleted.'); break; case DifferentialChangeType::FILE_DIRECTORY: $messages[] = pht('This directory was deleted.'); break; case DifferentialChangeType::FILE_BINARY: $messages[] = pht('This binary file was deleted.'); break; case DifferentialChangeType::FILE_SYMLINK: $messages[] = pht('This symlink was deleted.'); break; case DifferentialChangeType::FILE_SUBMODULE: $messages[] = pht('This submodule was deleted.'); break; } break; case DifferentialChangeType::TYPE_MOVE_HERE: $from = phutil_tag('strong', array(), $changeset->getOldFile()); switch ($file) { case DifferentialChangeType::FILE_TEXT: $messages[] = pht('This file was moved from %s.', $from); break; case DifferentialChangeType::FILE_IMAGE: $messages[] = pht('This image was moved from %s.', $from); break; case DifferentialChangeType::FILE_DIRECTORY: $messages[] = pht('This directory was moved from %s.', $from); break; case DifferentialChangeType::FILE_BINARY: $messages[] = pht('This binary file was moved from %s.', $from); break; case DifferentialChangeType::FILE_SYMLINK: $messages[] = pht('This symlink was moved from %s.', $from); break; case DifferentialChangeType::FILE_SUBMODULE: $messages[] = pht('This submodule was moved from %s.', $from); break; } break; case DifferentialChangeType::TYPE_COPY_HERE: $from = phutil_tag('strong', array(), $changeset->getOldFile()); switch ($file) { case DifferentialChangeType::FILE_TEXT: $messages[] = pht('This file was copied from %s.', $from); break; case DifferentialChangeType::FILE_IMAGE: $messages[] = pht('This image was copied from %s.', $from); break; case DifferentialChangeType::FILE_DIRECTORY: $messages[] = pht('This directory was copied from %s.', $from); break; case DifferentialChangeType::FILE_BINARY: $messages[] = pht('This binary file was copied from %s.', $from); break; case DifferentialChangeType::FILE_SYMLINK: $messages[] = pht('This symlink was copied from %s.', $from); break; case DifferentialChangeType::FILE_SUBMODULE: $messages[] = pht('This submodule was copied from %s.', $from); break; } break; case DifferentialChangeType::TYPE_MOVE_AWAY: $paths = phutil_tag( 'strong', array(), implode(', ', $changeset->getAwayPaths())); switch ($file) { case DifferentialChangeType::FILE_TEXT: $messages[] = pht('This file was moved to %s.', $paths); break; case DifferentialChangeType::FILE_IMAGE: $messages[] = pht('This image was moved to %s.', $paths); break; case DifferentialChangeType::FILE_DIRECTORY: $messages[] = pht('This directory was moved to %s.', $paths); break; case DifferentialChangeType::FILE_BINARY: $messages[] = pht('This binary file was moved to %s.', $paths); break; case DifferentialChangeType::FILE_SYMLINK: $messages[] = pht('This symlink was moved to %s.', $paths); break; case DifferentialChangeType::FILE_SUBMODULE: $messages[] = pht('This submodule was moved to %s.', $paths); break; } break; case DifferentialChangeType::TYPE_COPY_AWAY: $paths = phutil_tag( 'strong', array(), implode(', ', $changeset->getAwayPaths())); switch ($file) { case DifferentialChangeType::FILE_TEXT: $messages[] = pht('This file was copied to %s.', $paths); break; case DifferentialChangeType::FILE_IMAGE: $messages[] = pht('This image was copied to %s.', $paths); break; case DifferentialChangeType::FILE_DIRECTORY: $messages[] = pht('This directory was copied to %s.', $paths); break; case DifferentialChangeType::FILE_BINARY: $messages[] = pht('This binary file was copied to %s.', $paths); break; case DifferentialChangeType::FILE_SYMLINK: $messages[] = pht('This symlink was copied to %s.', $paths); break; case DifferentialChangeType::FILE_SUBMODULE: $messages[] = pht('This submodule was copied to %s.', $paths); break; } break; case DifferentialChangeType::TYPE_MULTICOPY: $paths = phutil_tag( 'strong', array(), implode(', ', $changeset->getAwayPaths())); switch ($file) { case DifferentialChangeType::FILE_TEXT: $messages[] = pht( 'This file was deleted after being copied to %s.', $paths); break; case DifferentialChangeType::FILE_IMAGE: $messages[] = pht( 'This image was deleted after being copied to %s.', $paths); break; case DifferentialChangeType::FILE_DIRECTORY: $messages[] = pht( 'This directory was deleted after being copied to %s.', $paths); break; case DifferentialChangeType::FILE_BINARY: $messages[] = pht( 'This binary file was deleted after being copied to %s.', $paths); break; case DifferentialChangeType::FILE_SYMLINK: $messages[] = pht( 'This symlink was deleted after being copied to %s.', $paths); break; case DifferentialChangeType::FILE_SUBMODULE: $messages[] = pht( 'This submodule was deleted after being copied to %s.', $paths); break; } break; default: switch ($file) { case DifferentialChangeType::FILE_TEXT: // This is the default case, so we only render this header if // forced to since it's not very useful. if ($force) { $messages[] = pht('This file was not modified.'); } break; case DifferentialChangeType::FILE_IMAGE: $messages[] = pht('This is an image.'); break; case DifferentialChangeType::FILE_DIRECTORY: $messages[] = pht('This is a directory.'); break; case DifferentialChangeType::FILE_BINARY: $messages[] = pht('This is a binary file.'); break; case DifferentialChangeType::FILE_SYMLINK: $messages[] = pht('This is a symlink.'); break; case DifferentialChangeType::FILE_SUBMODULE: $messages[] = pht('This is a submodule.'); break; } break; } return $this->formatHeaderMessages($messages); } protected function renderUndershieldHeader() { $messages = array(); $changeset = $this->getChangeset(); $file = $changeset->getFileType(); // If this is a text file with at least one hunk, we may have converted // the text encoding. In this case, show a note. $show_encoding = ($file == DifferentialChangeType::FILE_TEXT) && ($changeset->getHunks()); if ($show_encoding) { $encoding = $this->getOriginalCharacterEncoding(); if ($encoding != 'utf8') { if ($encoding) { $messages[] = pht( 'This file was converted from %s for display.', phutil_tag('strong', array(), $encoding)); } else { $messages[] = pht('This file uses an unknown character encoding.'); } } } $blocks = $this->getDocumentEngineBlocks(); if ($blocks) { foreach ($blocks->getMessages() as $message) { $messages[] = $message; } } else { if ($this->getHighlightingDisabled()) { $byte_limit = DifferentialChangesetParser::HIGHLIGHT_BYTE_LIMIT; $byte_limit = phutil_format_bytes($byte_limit); $messages[] = pht( 'This file is larger than %s, so syntax highlighting is '. 'disabled by default.', $byte_limit); } } return $this->formatHeaderMessages($messages); } private function formatHeaderMessages(array $messages) { if (!$messages) { return null; } foreach ($messages as $key => $message) { $messages[$key] = phutil_tag('li', array(), $message); } return phutil_tag( 'ul', array( 'class' => 'differential-meta-notice', ), $messages); } protected function renderPropertyChangeHeader() { $changeset = $this->getChangeset(); list($old, $new) = $this->getChangesetProperties($changeset); // If we don't have any property changes, don't render this table. if ($old === $new) { return null; } $keys = array_keys($old + $new); sort($keys); $key_map = array( 'unix:filemode' => pht('File Mode'), 'file:dimensions' => pht('Image Dimensions'), 'file:mimetype' => pht('MIME Type'), 'file:size' => pht('File Size'), ); $rows = array(); foreach ($keys as $key) { $oval = idx($old, $key); $nval = idx($new, $key); if ($oval !== $nval) { if ($oval === null) { $oval = phutil_tag('em', array(), 'null'); } else { $oval = phutil_escape_html_newlines($oval); } if ($nval === null) { $nval = phutil_tag('em', array(), 'null'); } else { $nval = phutil_escape_html_newlines($nval); } $readable_key = idx($key_map, $key, $key); $row = array( $readable_key, $oval, $nval, ); $rows[] = $row; } } $classes = array('', 'oval', 'nval'); $headers = array( pht('Property'), pht('Old Value'), pht('New Value'), ); $table = id(new AphrontTableView($rows)) ->setHeaders($headers) ->setColumnClasses($classes); return phutil_tag( 'div', array( 'class' => 'differential-property-table', ), $table); } public function renderShield($message, $force = 'default') { $end = count($this->getOldLines()); $reference = $this->getRenderingReference(); if ($force !== 'text' && $force !== 'none' && $force !== 'default') { throw new Exception( pht( "Invalid '%s' parameter '%s'!", 'force', $force)); } $range = "0-{$end}"; if ($force == 'text') { // If we're forcing text, force the whole file to be rendered. $range = "{$range}/0-{$end}"; } $meta = array( 'ref' => $reference, 'range' => $range, ); $content = array(); $content[] = $message; if ($force !== 'none') { $content[] = ' '; $content[] = javelin_tag( 'a', array( 'mustcapture' => true, 'sigil' => 'show-more', 'class' => 'complete', 'href' => '#', 'meta' => $meta, ), pht('Show File Contents')); } return $this->wrapChangeInTable( javelin_tag( 'tr', array( 'sigil' => 'context-target', ), phutil_tag( 'td', array( 'class' => 'differential-shield', 'colspan' => 6, ), $content))); } abstract protected function renderColgroup(); protected function wrapChangeInTable($content) { if (!$content) { return null; } $classes = array(); $classes[] = 'differential-diff'; $classes[] = 'remarkup-code'; $classes[] = 'PhabricatorMonospaced'; $classes[] = $this->getRendererTableClass(); $sigils = array(); $sigils[] = 'differential-diff'; foreach ($this->getTableSigils() as $sigil) { $sigils[] = $sigil; } return javelin_tag( 'table', array( 'class' => implode(' ', $classes), 'sigil' => implode(' ', $sigils), ), array( $this->renderColgroup(), $content, )); } protected function getTableSigils() { return array(); } protected function buildInlineComment( PhabricatorInlineComment $comment, $on_right = false) { $viewer = $this->getUser(); $edit = $viewer && ($comment->getAuthorPHID() == $viewer->getPHID()) && ($comment->isDraft()) && $this->getShowEditAndReplyLinks(); $allow_reply = (bool)$viewer && $this->getShowEditAndReplyLinks(); $allow_done = !$comment->isDraft() && $this->getCanMarkDone(); return id(new PHUIDiffInlineCommentDetailView()) ->setViewer($viewer) ->setInlineComment($comment) ->setIsOnRight($on_right) ->setHandles($this->getHandles()) ->setMarkupEngine($this->getMarkupEngine()) ->setEditable($edit) ->setAllowReply($allow_reply) ->setCanMarkDone($allow_done) ->setObjectOwnerPHID($this->getObjectOwnerPHID()); } /** * Build links which users can click to show more context in a changeset. * * @param int Beginning of the line range to build links for. * @param int Length of the line range to build links for. * @param int Total number of lines in the changeset. * @return markup Rendered links. */ - protected function renderShowContextLinks($top, $len, $changeset_length) { + protected function renderShowContextLinks( + $top, + $len, + $changeset_length, + $is_blocks = false) { + $block_size = 20; $end = ($top + $len) - $block_size; // If this is a large block, such that the "top" and "bottom" ranges are // non-overlapping, we'll provide options to show the top, bottom or entire // block. For smaller blocks, we only provide an option to show the entire // block, since it would be silly to show the bottom 20 lines of a 25-line // block. $is_large_block = ($len > ($block_size * 2)); $links = array(); + $block_display = new PhutilNumber($block_size); + if ($is_large_block) { $is_first_block = ($top == 0); if ($is_first_block) { - $text = pht('Show First %d Line(s)', $block_size); + if ($is_blocks) { + $text = pht('Show First %s Block(s)', $block_display); + } else { + $text = pht('Show First %s Line(s)', $block_display); + } } else { - $text = pht("\xE2\x96\xB2 Show %d Line(s)", $block_size); + if ($is_blocks) { + $text = pht("\xE2\x96\xB2 Show %s Block(s)", $block_display); + } else { + $text = pht("\xE2\x96\xB2 Show %s Line(s)", $block_display); + } } $links[] = $this->renderShowContextLink( false, "{$top}-{$len}/{$top}-20", $text); } + if ($is_blocks) { + $text = pht('Show All %s Block(s)', new PhutilNumber($len)); + } else { + $text = pht('Show All %s Line(s)', new PhutilNumber($len)); + } + $links[] = $this->renderShowContextLink( true, "{$top}-{$len}/{$top}-{$len}", - pht('Show All %d Line(s)', $len)); + $text); if ($is_large_block) { $is_last_block = (($top + $len) >= $changeset_length); if ($is_last_block) { - $text = pht('Show Last %d Line(s)', $block_size); + if ($is_blocks) { + $text = pht('Show Last %s Block(s)', $block_display); + } else { + $text = pht('Show Last %s Line(s)', $block_display); + } } else { - $text = "\xE2\x96\xBC ".pht('Show %d Line(s)', $block_size); + if ($is_blocks) { + $text = pht("\xE2\x96\xBC Show %s Block(s)", $block_display); + } else { + $text = pht("\xE2\x96\xBC Show %s Line(s)", $block_display); + } } $links[] = $this->renderShowContextLink( false, "{$top}-{$len}/{$end}-20", $text); } return phutil_implode_html(" \xE2\x80\xA2 ", $links); } /** * Build a link that shows more context in a changeset. * * See @{method:renderShowContextLinks}. * * @param bool Does this link show all context when clicked? * @param string Range specification for lines to show. * @param string Text of the link. * @return markup Rendered link. */ private function renderShowContextLink($is_all, $range, $text) { $reference = $this->getRenderingReference(); return javelin_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'sigil' => 'show-more', 'meta' => array( 'type' => ($is_all ? 'all' : null), 'range' => $range, ), ), $text); } /** * Build the prefixes for line IDs used to track inline comments. * * @return pair Left and right prefixes. */ protected function getLineIDPrefixes() { // These look like "C123NL45", which means the line is line 45 on the // "new" side of the file in changeset 123. // The "C" stands for "changeset", and is followed by a changeset ID. // "N" stands for "new" and means the comment should attach to the new file // when stored. "O" stands for "old" and means the comment should attach to // the old file. These are important because either the old or new part // of a file may appear on the left or right side of the diff in the // diff-of-diffs view. // The "L" stands for "line" and is followed by the line number. if ($this->getOldChangesetID()) { $left_prefix = array(); $left_prefix[] = 'C'; $left_prefix[] = $this->getOldChangesetID(); $left_prefix[] = $this->getOldAttachesToNewFile() ? 'N' : 'O'; $left_prefix[] = 'L'; $left_prefix = implode('', $left_prefix); } else { $left_prefix = null; } if ($this->getNewChangesetID()) { $right_prefix = array(); $right_prefix[] = 'C'; $right_prefix[] = $this->getNewChangesetID(); $right_prefix[] = $this->getNewAttachesToNewFile() ? 'N' : 'O'; $right_prefix[] = 'L'; $right_prefix = implode('', $right_prefix); } else { $right_prefix = null; } return array($left_prefix, $right_prefix); } } diff --git a/src/applications/differential/render/DifferentialChangesetOneUpRenderer.php b/src/applications/differential/render/DifferentialChangesetOneUpRenderer.php index b3e6520fd9..f4ed05545b 100644 --- a/src/applications/differential/render/DifferentialChangesetOneUpRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetOneUpRenderer.php @@ -1,481 +1,491 @@ 'num')), phutil_tag('col', array('class' => 'num')), phutil_tag('col', array('class' => 'copy')), phutil_tag('col', array('class' => 'unified')), )); } public function renderTextChange( $range_start, $range_len, $rows) { $primitives = $this->buildPrimitives($range_start, $range_len); return $this->renderPrimitives($primitives, $rows); } protected function renderPrimitives(array $primitives, $rows) { list($left_prefix, $right_prefix) = $this->getLineIDPrefixes(); $no_copy = phutil_tag('td', array('class' => 'copy')); $no_coverage = null; $column_width = 4; $aural_minus = javelin_tag( 'span', array( 'aural' => true, + 'data-aural' => true, ), '- '); $aural_plus = javelin_tag( 'span', array( 'aural' => true, + 'data-aural' => true, ), '+ '); $out = array(); foreach ($primitives as $k => $p) { $type = $p['type']; switch ($type) { case 'old': case 'new': case 'old-file': case 'new-file': $is_old = ($type == 'old' || $type == 'old-file'); $cells = array(); if ($is_old) { if ($p['htype']) { if (empty($p['oline'])) { $class = 'left old old-full'; } else { $class = 'left old'; } $aural = $aural_minus; } else { $class = 'left'; $aural = null; } if ($type == 'old-file') { $class = "{$class} differential-old-image"; } if ($left_prefix) { $left_id = $left_prefix.$p['line']; } else { $left_id = null; } $line = $p['line']; $cells[] = phutil_tag( 'td', array( 'id' => $left_id, 'class' => $class.' n', 'data-n' => $line, )); $render = $p['render']; if ($aural !== null) { $render = array($aural, $render); } $cells[] = phutil_tag( 'td', array( 'class' => $class.' n', )); $cells[] = $no_copy; $cells[] = phutil_tag('td', array('class' => $class), $render); $cells[] = $no_coverage; } else { if ($p['htype']) { if (empty($p['oline'])) { $class = 'right new new-full'; } else { $class = 'right new'; } $cells[] = phutil_tag( 'td', array( 'class' => $class.' n', )); $aural = $aural_plus; } else { $class = 'right'; if ($left_prefix) { $left_id = $left_prefix.$p['oline']; } else { $left_id = null; } $oline = $p['oline']; $cells[] = phutil_tag( 'td', array( 'id' => $left_id, 'class' => 'n', 'data-n' => $oline, )); $aural = null; } if ($type == 'new-file') { $class = "{$class} differential-new-image"; } if ($right_prefix) { $right_id = $right_prefix.$p['line']; } else { $right_id = null; } $line = $p['line']; $cells[] = phutil_tag( 'td', array( 'id' => $right_id, 'class' => $class.' n', 'data-n' => $line, )); $render = $p['render']; if ($aural !== null) { $render = array($aural, $render); } $cells[] = $no_copy; - $cells[] = phutil_tag('td', array('class' => $class), $render); + + $cells[] = phutil_tag( + 'td', + array( + 'class' => $class, + 'data-copy-mode' => 'copy-unified', + ), + $render); + $cells[] = $no_coverage; } $out[] = phutil_tag('tr', array(), $cells); break; case 'inline': $inline = $this->buildInlineComment( $p['comment'], $p['right']); $out[] = $this->getRowScaffoldForInline($inline); break; case 'no-context': $out[] = phutil_tag( 'tr', array(), phutil_tag( 'td', array( 'class' => 'show-more', 'colspan' => $column_width, ), pht('Context not available.'))); break; case 'context': $top = $p['top']; $len = $p['len']; $links = $this->renderShowContextLinks($top, $len, $rows); $out[] = javelin_tag( 'tr', array( 'sigil' => 'context-target', ), phutil_tag( 'td', array( 'class' => 'show-more', 'colspan' => $column_width, ), $links)); break; default: $out[] = hsprintf('%s', $type); break; } } if ($out) { return $this->wrapChangeInTable(phutil_implode_html('', $out)); } return null; } public function renderDocumentEngineBlocks( PhabricatorDocumentEngineBlocks $block_list, $old_changeset_key, $new_changeset_key) { $engine = $this->getDocumentEngine(); $layout = $block_list->newTwoUpLayout(); $old_comments = $this->getOldComments(); $new_comments = $this->getNewComments(); $unchanged = array(); foreach ($layout as $key => $row) { list($old, $new) = $row; if (!$old) { continue; } if (!$new) { continue; } if ($old->getDifferenceType() !== null) { continue; } if ($new->getDifferenceType() !== null) { continue; } $unchanged[$key] = true; } $rows = array(); $count = count($layout); for ($ii = 0; $ii < $count;) { $start = $ii; for ($jj = $ii; $jj < $count; $jj++) { list($old, $new) = $layout[$jj]; if (empty($unchanged[$jj])) { break; } $rows[] = array( 'type' => 'unchanged', 'layoutKey' => $jj, ); } $ii = $jj; for ($jj = $ii; $jj < $count; $jj++) { list($old, $new) = $layout[$jj]; if (!empty($unchanged[$jj])) { break; } $rows[] = array( 'type' => 'old', 'layoutKey' => $jj, ); } for ($jj = $ii; $jj < $count; $jj++) { list($old, $new) = $layout[$jj]; if (!empty($unchanged[$jj])) { break; } $rows[] = array( 'type' => 'new', 'layoutKey' => $jj, ); } $ii = $jj; // We always expect to consume at least one row when iterating through // the loop and make progress. If we don't, bail out to avoid spinning // to death. if ($ii === $start) { throw new Exception( pht( 'Failed to make progress during 1up diff layout.')); } } $old_ref = null; $new_ref = null; $refs = $block_list->getDocumentRefs(); if ($refs) { list($old_ref, $new_ref) = $refs; } $view = array(); foreach ($rows as $row) { $row_type = $row['type']; $layout_key = $row['layoutKey']; $row_layout = $layout[$layout_key]; list($old, $new) = $row_layout; if ($old) { $old_key = $old->getBlockKey(); } else { $old_key = null; } if ($new) { $new_key = $new->getBlockKey(); } else { $new_key = null; } $cells = array(); $cell_classes = array(); if ($row_type === 'unchanged') { $cell_content = $engine->newBlockContentView( $old_ref, $old); } else if ($old && $new) { $block_diff = $engine->newBlockDiffViews( $old_ref, $old, $new_ref, $new); // TODO: We're currently double-rendering this: once when building // the old row, and once when building the new one. In both cases, // we throw away the other half of the output. We could cache this // to improve performance. if ($row_type === 'old') { $cell_content = $block_diff->getOldContent(); $cell_classes = $block_diff->getOldClasses(); } else { $cell_content = $block_diff->getNewContent(); $cell_classes = $block_diff->getNewClasses(); } } else if ($row_type === 'old') { if (!$old_ref || !$old) { continue; } $cell_content = $engine->newBlockContentView( $old_ref, $old); $cell_classes[] = 'old'; $cell_classes[] = 'old-full'; $new_key = null; } else if ($row_type === 'new') { if (!$new_ref || !$new) { continue; } $cell_content = $engine->newBlockContentView( $new_ref, $new); $cell_classes[] = 'new'; $cell_classes[] = 'new-full'; $old_key = null; } if ($old_key === null) { $old_id = null; } else { $old_id = "C{$old_changeset_key}OL{$old_key}"; } if ($new_key === null) { $new_id = null; } else { $new_id = "C{$new_changeset_key}NL{$new_key}"; } $cells[] = phutil_tag( 'td', array( 'id' => $old_id, 'data-n' => $old_key, 'class' => 'n', )); $cells[] = phutil_tag( 'td', array( 'id' => $new_id, 'data-n' => $new_key, 'class' => 'n', )); $cells[] = phutil_tag( 'td', array( 'class' => 'copy', )); $cell_classes[] = 'diff-flush'; $cell_classes = implode(' ', $cell_classes); $cells[] = phutil_tag( 'td', array( 'class' => $cell_classes, 'data-copy-mode' => 'copy-unified', ), $cell_content); $view[] = phutil_tag( 'tr', array(), $cells); if ($old_key !== null) { $old_inlines = idx($old_comments, $old_key, array()); foreach ($old_inlines as $inline) { $inline = $this->buildInlineComment( $inline, $on_right = false); $view[] = $this->getRowScaffoldForInline($inline); } } if ($new_key !== null) { $new_inlines = idx($new_comments, $new_key, array()); foreach ($new_inlines as $inline) { $inline = $this->buildInlineComment( $inline, $on_right = true); $view[] = $this->getRowScaffoldForInline($inline); } } } $output = $this->wrapChangeInTable($view); return $this->renderChangesetTable($output); } public function getRowScaffoldForInline(PHUIDiffInlineCommentView $view) { return id(new PHUIDiffOneUpInlineCommentRowScaffold()) ->addInlineView($view); } } diff --git a/src/applications/differential/render/DifferentialChangesetTwoUpRenderer.php b/src/applications/differential/render/DifferentialChangesetTwoUpRenderer.php index 3380e66c52..3293dc1a0e 100644 --- a/src/applications/differential/render/DifferentialChangesetTwoUpRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetTwoUpRenderer.php @@ -1,619 +1,645 @@ 'num')), phutil_tag('col', array('class' => 'left')), phutil_tag('col', array('class' => 'num')), phutil_tag('col', array('class' => 'copy')), phutil_tag('col', array('class' => 'right')), phutil_tag('col', array('class' => 'cov')), )); } public function renderTextChange( $range_start, $range_len, $rows) { $hunk_starts = $this->getHunkStartLines(); $context_not_available = null; if ($hunk_starts) { $context_not_available = javelin_tag( 'tr', array( 'sigil' => 'context-target', ), phutil_tag( 'td', array( 'colspan' => 6, 'class' => 'show-more', ), pht('Context not available.'))); } $html = array(); $old_lines = $this->getOldLines(); $new_lines = $this->getNewLines(); $gaps = $this->getGaps(); $reference = $this->getRenderingReference(); list($left_prefix, $right_prefix) = $this->getLineIDPrefixes(); $changeset = $this->getChangeset(); $copy_lines = idx($changeset->getMetadata(), 'copy:lines', array()); $highlight_old = $this->getHighlightOld(); $highlight_new = $this->getHighlightNew(); $old_render = $this->getOldRender(); $new_render = $this->getNewRender(); $original_left = $this->getOriginalOld(); $original_right = $this->getOriginalNew(); $mask = $this->getMask(); $scope_engine = $this->getScopeEngine(); $offset_map = null; $depth_only = $this->getDepthOnlyLines(); 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]; $contents = $this->renderShowContextLinks($top, $len, $rows); $is_last_block = false; if ($ii + $len >= $rows) { $is_last_block = true; } $context_text = null; $context_line = null; if (!$is_last_block && $scope_engine) { $target_line = $new_lines[$ii + $len]['line']; $context_line = $scope_engine->getScopeStart($target_line); if ($context_line !== null) { // The scope engine returns a line number in the file. We need // to map that back to a display offset in the diff. if (!$offset_map) { $offset_map = $this->getNewLineToOffsetMap(); } $offset = $offset_map[$context_line]; $context_text = $new_render[$offset]; } } $container = javelin_tag( 'tr', array( 'sigil' => 'context-target', ), array( phutil_tag( 'td', array( 'class' => 'show-context-line n left-context', )), phutil_tag( 'td', array( 'class' => 'show-more', ), $contents), phutil_tag( 'td', array( 'class' => 'show-context-line n', 'data-n' => $context_line, )), phutil_tag( 'td', array( 'colspan' => 3, 'class' => 'show-context', ), // TODO: [HTML] Escaping model here isn't ideal. phutil_safe_html($context_text)), )); $html[] = $container; $ii += ($len - 1); continue; } $o_num = null; $o_classes = ''; $o_text = null; if (isset($old_lines[$ii])) { $o_num = $old_lines[$ii]['line']; $o_text = isset($old_render[$ii]) ? $old_render[$ii] : null; if ($old_lines[$ii]['type']) { if ($old_lines[$ii]['type'] == '\\') { $o_text = $old_lines[$ii]['text']; $o_class = 'comment'; } else if ($original_left && !isset($highlight_old[$o_num])) { $o_class = 'old-rebase'; } else if (empty($new_lines[$ii])) { $o_class = 'old old-full'; } else { if (isset($depth_only[$ii])) { if ($depth_only[$ii] == '>') { // When a line has depth-only change, we only highlight the // left side of the diff if the depth is decreasing. When the // depth is increasing, the ">>" marker on the right hand side // of the diff generally provides enough visibility on its own. $o_class = ''; } else { $o_class = 'old'; } } else { $o_class = 'old'; } } $o_classes = $o_class; } } $n_copy = hsprintf(''); $n_cov = null; $n_colspan = 2; $n_classes = ''; $n_num = null; $n_text = null; if (isset($new_lines[$ii])) { $n_num = $new_lines[$ii]['line']; $n_text = isset($new_render[$ii]) ? $new_render[$ii] : null; $coverage = $this->getCodeCoverage(); if ($coverage !== null) { if (empty($coverage[$n_num - 1])) { $cov_class = 'N'; } else { $cov_class = $coverage[$n_num - 1]; } $cov_class = 'cov-'.$cov_class; $n_cov = phutil_tag('td', array('class' => "cov {$cov_class}")); $n_colspan--; } if ($new_lines[$ii]['type']) { if ($new_lines[$ii]['type'] == '\\') { $n_text = $new_lines[$ii]['text']; $n_class = 'comment'; } else if ($original_right && !isset($highlight_new[$n_num])) { $n_class = 'new-rebase'; } else if (empty($old_lines[$ii])) { $n_class = 'new new-full'; } else { // When a line has a depth-only change, never highlight it on // the right side. The ">>" marker generally provides enough // visibility on its own for indent depth increases, and the left // side is still highlighted for indent depth decreases. if (isset($depth_only[$ii])) { $n_class = ''; } else { $n_class = 'new'; } } $n_classes = $n_class; $not_copied = // If this line only changed depth, copy markers are pointless. (!isset($copy_lines[$n_num])) || (isset($depth_only[$ii])) || ($new_lines[$ii]['type'] == '\\'); if ($not_copied) { $n_copy = phutil_tag('td', array('class' => 'copy')); } else { list($orig_file, $orig_line, $orig_type) = $copy_lines[$n_num]; $title = ($orig_type == '-' ? 'Moved' : 'Copied').' from '; if ($orig_file == '') { $title .= "line {$orig_line}"; } else { $title .= basename($orig_file). ":{$orig_line} in dir ". dirname('/'.$orig_file); } $class = ($orig_type == '-' ? 'new-move' : 'new-copy'); $n_copy = javelin_tag( 'td', array( 'meta' => array( 'msg' => $title, ), 'class' => 'copy '.$class, )); } } } if (isset($hunk_starts[$o_num])) { $html[] = $context_not_available; } if ($o_num && $left_prefix) { $o_id = $left_prefix.$o_num; } else { $o_id = null; } if ($n_num && $right_prefix) { $n_id = $right_prefix.$n_num; } else { $n_id = null; } $old_comments = $this->getOldComments(); $new_comments = $this->getNewComments(); $scaffolds = array(); if ($o_num && isset($old_comments[$o_num])) { foreach ($old_comments[$o_num] as $comment) { $inline = $this->buildInlineComment( $comment, $on_right = false); $scaffold = $this->getRowScaffoldForInline($inline); if ($n_num && isset($new_comments[$n_num])) { foreach ($new_comments[$n_num] as $key => $new_comment) { if ($comment->isCompatible($new_comment)) { $companion = $this->buildInlineComment( $new_comment, $on_right = true); $scaffold->addInlineView($companion); unset($new_comments[$n_num][$key]); break; } } } $scaffolds[] = $scaffold; } } if ($n_num && isset($new_comments[$n_num])) { foreach ($new_comments[$n_num] as $comment) { $inline = $this->buildInlineComment( $comment, $on_right = true); $scaffolds[] = $this->getRowScaffoldForInline($inline); } } $old_number = phutil_tag( 'td', array( 'id' => $o_id, 'class' => $o_classes.' n', 'data-n' => $o_num, )); $new_number = phutil_tag( 'td', array( 'id' => $n_id, 'class' => $n_classes.' n', 'data-n' => $n_num, )); $html[] = phutil_tag('tr', array(), array( $old_number, phutil_tag( 'td', array( 'class' => $o_classes, 'data-copy-mode' => 'copy-l', ), $o_text), $new_number, $n_copy, phutil_tag( 'td', array( 'class' => $n_classes, 'colspan' => $n_colspan, 'data-copy-mode' => 'copy-r', ), $n_text), $n_cov, )); if ($context_not_available && ($ii == $rows - 1)) { $html[] = $context_not_available; } foreach ($scaffolds as $scaffold) { $html[] = $scaffold; } } return $this->wrapChangeInTable(phutil_implode_html('', $html)); } public function renderDocumentEngineBlocks( PhabricatorDocumentEngineBlocks $block_list, $old_changeset_key, $new_changeset_key) { $engine = $this->getDocumentEngine(); $old_ref = null; $new_ref = null; $refs = $block_list->getDocumentRefs(); if ($refs) { list($old_ref, $new_ref) = $refs; } $old_comments = $this->getOldComments(); $new_comments = $this->getNewComments(); - $gap_view = javelin_tag( - 'tr', - array( - 'sigil' => 'context-target', - ), - phutil_tag( - 'td', - array( - 'colspan' => 6, - 'class' => 'show-more', - ), - pht("\xE2\x80\xA2 \xE2\x80\xA2 \xE2\x80\xA2"))); - $rows = array(); + $gap = array(); $in_gap = false; - foreach ($block_list->newTwoUpLayout() as $row) { + + // NOTE: The generated layout is affected by range constraints, and may + // represent only a slice of the document. + + $layout = $block_list->newTwoUpLayout(); + $available_count = $block_list->getLayoutAvailableRowCount(); + + foreach ($layout as $idx => $row) { list($old, $new) = $row; if ($old) { $old_key = $old->getBlockKey(); $is_visible = $old->getIsVisible(); } else { $old_key = null; } if ($new) { $new_key = $new->getBlockKey(); $is_visible = $new->getIsVisible(); } else { $new_key = null; } if (!$is_visible) { if (!$in_gap) { $in_gap = true; - $rows[] = $gap_view; } + $gap[$idx] = $row; continue; } if ($in_gap) { $in_gap = false; + $rows[] = $this->renderDocumentEngineGap( + $gap, + $available_count); + $gap = array(); } if ($old) { $is_rem = ($old->getDifferenceType() === '-'); } else { $is_rem = false; } if ($new) { $is_add = ($new->getDifferenceType() === '+'); } else { $is_add = false; } if ($is_rem && $is_add) { $block_diff = $engine->newBlockDiffViews( $old_ref, $old, $new_ref, $new); $old_content = $block_diff->getOldContent(); $new_content = $block_diff->getNewContent(); $old_classes = $block_diff->getOldClasses(); $new_classes = $block_diff->getNewClasses(); } else { $old_classes = array(); $new_classes = array(); if ($old) { $old_content = $engine->newBlockContentView( $old_ref, $old); if ($is_rem) { $old_classes[] = 'old'; $old_classes[] = 'old-full'; } } else { $old_content = null; } if ($new) { $new_content = $engine->newBlockContentView( $new_ref, $new); if ($is_add) { $new_classes[] = 'new'; $new_classes[] = 'new-full'; } } else { $new_content = null; } } $old_classes[] = 'diff-flush'; $old_classes = implode(' ', $old_classes); $new_classes[] = 'diff-flush'; $new_classes = implode(' ', $new_classes); $old_inline_rows = array(); if ($old_key !== null) { $old_inlines = idx($old_comments, $old_key, array()); foreach ($old_inlines as $inline) { $inline = $this->buildInlineComment( $inline, $on_right = false); $old_inline_rows[] = $this->getRowScaffoldForInline($inline); } } $new_inline_rows = array(); if ($new_key !== null) { $new_inlines = idx($new_comments, $new_key, array()); foreach ($new_inlines as $inline) { $inline = $this->buildInlineComment( $inline, $on_right = true); $new_inline_rows[] = $this->getRowScaffoldForInline($inline); } } if ($old_content === null) { $old_id = null; } else { $old_id = "C{$old_changeset_key}OL{$old_key}"; } $old_line_cell = phutil_tag( 'td', array( 'id' => $old_id, 'data-n' => $old_key, 'class' => 'n', )); $old_content_cell = phutil_tag( 'td', array( 'class' => $old_classes, 'data-copy-mode' => 'copy-l', ), $old_content); if ($new_content === null) { $new_id = null; } else { $new_id = "C{$new_changeset_key}NL{$new_key}"; } $new_line_cell = phutil_tag( 'td', array( 'id' => $new_id, 'data-n' => $new_key, 'class' => 'n', )); $copy_gutter = phutil_tag( 'td', array( 'class' => 'copy', )); $new_content_cell = phutil_tag( 'td', array( 'class' => $new_classes, 'colspan' => '2', 'data-copy-mode' => 'copy-r', ), $new_content); $row_view = phutil_tag( 'tr', array(), array( $old_line_cell, $old_content_cell, $new_line_cell, $copy_gutter, $new_content_cell, )); $rows[] = array( $row_view, $old_inline_rows, $new_inline_rows, ); } + if ($in_gap) { + $rows[] = $this->renderDocumentEngineGap( + $gap, + $available_count); + } + $output = $this->wrapChangeInTable($rows); return $this->renderChangesetTable($output); } public function getRowScaffoldForInline(PHUIDiffInlineCommentView $view) { return id(new PHUIDiffTwoUpInlineCommentRowScaffold()) ->addInlineView($view); } private function getNewLineToOffsetMap() { if ($this->newOffsetMap === null) { $new = $this->getNewLines(); $map = array(); foreach ($new as $offset => $new_line) { if ($new_line === null) { continue; } if ($new_line['line'] === null) { continue; } $map[$new_line['line']] = $offset; } $this->newOffsetMap = $map; } return $this->newOffsetMap; } protected function getTableSigils() { return array( 'intercept-copy', ); } + private function renderDocumentEngineGap(array $gap, $available_count) { + $content = $this->renderShowContextLinks( + head_key($gap), + count($gap), + $available_count, + $is_blocks = true); + + return javelin_tag( + 'tr', + array( + 'sigil' => 'context-target', + ), + phutil_tag( + 'td', + array( + 'colspan' => 6, + 'class' => 'show-more', + ), + $content)); + } + } diff --git a/src/applications/differential/view/DifferentialChangesetListView.php b/src/applications/differential/view/DifferentialChangesetListView.php index 0c9dad52bc..bf8aa8fae5 100644 --- a/src/applications/differential/view/DifferentialChangesetListView.php +++ b/src/applications/differential/view/DifferentialChangesetListView.php @@ -1,463 +1,471 @@ parser = $parser; return $this; } public function getParser() { return $this->parser; } public function setTitle($title) { $this->title = $title; return $this; } private function getTitle() { return $this->title; } public function setBranch($branch) { $this->branch = $branch; return $this; } private function getBranch() { return $this->branch; } public function setChangesets($changesets) { $this->changesets = $changesets; return $this; } public function setVisibleChangesets($visible_changesets) { $this->visibleChangesets = $visible_changesets; return $this; } public function setInlineCommentControllerURI($uri) { $this->inlineURI = $uri; return $this; } public function setInlineListURI($uri) { $this->inlineListURI = $uri; return $this; } public function getInlineListURI() { return $this->inlineListURI; } public function setRepository(PhabricatorRepository $repository) { $this->repository = $repository; return $this; } public function getRepository() { return $this->repository; } public function setDiff(DifferentialDiff $diff) { $this->diff = $diff; return $this; } public function getDiff() { return $this->diff; } public function setRenderingReferences(array $references) { $this->references = $references; return $this; } public function setSymbolIndexes(array $indexes) { $this->symbolIndexes = $indexes; return $this; } public function setRenderURI($render_uri) { $this->renderURI = $render_uri; return $this; } public function setVsMap(array $vs_map) { $this->vsMap = $vs_map; return $this; } public function getVsMap() { return $this->vsMap; } public function setStandaloneURI($uri) { $this->standaloneURI = $uri; return $this; } public function setRawFileURIs($l, $r) { $this->leftRawFileURI = $l; $this->rightRawFileURI = $r; return $this; } public function setIsStandalone($is_standalone) { $this->isStandalone = $is_standalone; return $this; } public function getIsStandalone() { return $this->isStandalone; } public function setBackground($background) { $this->background = $background; return $this; } public function setHeader($header) { $this->header = $header; return $this; } public function setFormationView(PHUIFormationView $formation_view) { $this->formationView = $formation_view; return $this; } public function getFormationView() { return $this->formationView; } public function render() { $viewer = $this->getViewer(); $this->requireResource('differential-changeset-view-css'); $changesets = $this->changesets; $repository = $this->getRepository(); $diff = $this->getDiff(); $output = array(); $ids = array(); foreach ($changesets as $key => $changeset) { $file = $changeset->getFilename(); $ref = $this->references[$key]; $detail = id(new DifferentialChangesetDetailView()) ->setViewer($viewer); if ($repository) { $detail->setRepository($repository); } if ($diff) { $detail->setDiff($diff); } $uniq_id = 'diff-'.$changeset->getAnchorName(); $detail->setID($uniq_id); $view_options = $this->renderViewOptionsDropdown( $detail, $ref, $changeset); $detail->setChangeset($changeset); $detail->addButton($view_options); $detail->setSymbolIndex(idx($this->symbolIndexes, $key)); $detail->setVsChangesetID(idx($this->vsMap, $changeset->getID())); $detail->setEditable(true); $detail->setRenderingRef($ref); $detail->setBranch($this->getBranch()); $detail->setRenderURI($this->renderURI); $parser = $this->getParser(); if ($parser) { $response = $parser->newChangesetResponse(); $detail->setChangesetResponse($response); } else { $detail->setAutoload(isset($this->visibleChangesets[$key])); if (isset($this->visibleChangesets[$key])) { $load = pht('Loading...'); } else { $load = javelin_tag( 'a', array( 'class' => 'button button-grey', 'href' => '#'.$uniq_id, 'sigil' => 'differential-load', 'meta' => array( 'id' => $detail->getID(), 'kill' => true, ), 'mustcapture' => true, ), pht('Load File')); } $detail->appendChild( phutil_tag( 'div', array( 'id' => $uniq_id, ), phutil_tag( 'div', array('class' => 'differential-loading'), $load))); } $output[] = $detail->render(); $ids[] = $detail->getID(); } $this->requireResource('aphront-tooltip-css'); $formation_id = null; $formation_view = $this->getFormationView(); if ($formation_view) { $formation_id = $formation_view->getID(); } $this->initBehavior( 'differential-populate', array( 'changesetViewIDs' => $ids, 'formationViewID' => $formation_id, 'inlineURI' => $this->inlineURI, 'inlineListURI' => $this->inlineListURI, 'isStandalone' => $this->getIsStandalone(), 'pht' => array( 'Open in Editor' => pht('Open in Editor'), 'Show All Context' => pht('Show All Context'), 'All Context Shown' => pht('All Context Shown'), 'Expand File' => pht('Expand File'), 'Hide Changeset' => pht('Hide Changeset'), 'Show Path in Repository' => pht('Show Path in Repository'), 'Show Directory in Repository' => pht('Show Directory in Repository'), 'View Standalone' => pht('View Standalone'), 'Show Raw File (Left)' => pht('Show Raw File (Left)'), 'Show Raw File (Right)' => pht('Show Raw File (Right)'), 'Configure Editor' => pht('Configure Editor'), 'Load Changes' => pht('Load Changes'), 'View Side-by-Side Diff' => pht('View Side-by-Side Diff'), 'View Unified Diff' => pht('View Unified Diff'), 'Change Text Encoding...' => pht('Change Text Encoding...'), 'Highlight As...' => pht('Highlight As...'), 'View As Document Type...' => pht('View As Document Type...'), 'Loading...' => pht('Loading...'), 'Editing Comment' => pht('Editing Comment'), 'Jump to next change.' => pht('Jump to next change.'), 'Jump to previous change.' => pht('Jump to previous change.'), 'Jump to next file.' => pht('Jump to next file.'), 'Jump to previous file.' => pht('Jump to previous file.'), 'Jump to next inline comment.' => pht('Jump to next inline comment.'), 'Jump to previous inline comment.' => pht('Jump to previous inline comment.'), 'Jump to the table of contents.' => pht('Jump to the table of contents.'), 'Edit selected inline comment.' => pht('Edit selected inline comment.'), 'You must select a comment to edit.' => pht('You must select a comment to edit.'), 'Reply to selected inline comment or change.' => pht('Reply to selected inline comment or change.'), 'You must select a comment or change to reply to.' => pht('You must select a comment or change to reply to.'), 'Reply and quote selected inline comment.' => pht('Reply and quote selected inline comment.'), 'Mark or unmark selected inline comment as done.' => pht('Mark or unmark selected inline comment as done.'), 'You must select a comment to mark done.' => pht('You must select a comment to mark done.'), 'Collapse or expand inline comment.' => pht('Collapse or expand inline comment.'), 'You must select a comment to hide.' => pht('You must select a comment to hide.'), 'Jump to next inline comment, including collapsed comments.' => pht('Jump to next inline comment, including collapsed comments.'), 'Jump to previous inline comment, including collapsed comments.' => pht('Jump to previous inline comment, including collapsed comments.'), 'Hide or show the current changeset.' => pht('Hide or show the current changeset.'), 'You must select a file to hide or show.' => pht('You must select a file to hide or show.'), 'Unsaved' => pht('Unsaved'), 'Unsubmitted' => pht('Unsubmitted'), 'Comments' => pht('Comments'), 'Hide "Done" Inlines' => pht('Hide "Done" Inlines'), 'Hide Collapsed Inlines' => pht('Hide Collapsed Inlines'), 'Hide Older Inlines' => pht('Hide Older Inlines'), 'Hide All Inlines' => pht('Hide All Inlines'), 'Show All Inlines' => pht('Show All Inlines'), 'List Inline Comments' => pht('List Inline Comments'), 'Display Options' => pht('Display Options'), 'Hide or show all inline comments.' => pht('Hide or show all inline comments.'), 'Finish editing inline comments before changing display modes.' => pht('Finish editing inline comments before changing display modes.'), 'Open file in external editor.' => pht('Open file in external editor.'), 'You must select a file to edit.' => pht('You must select a file to edit.'), 'You must select a file to open.' => pht('You must select a file to open.'), 'No external editor is configured.' => pht('No external editor is configured.'), 'Hide or show the paths panel.' => pht('Hide or show the paths panel.'), 'Show path in repository.' => pht('Show path in repository.'), 'Show directory in repository.' => pht('Show directory in repository.'), 'Jump to the comment area.' => pht('Jump to the comment area.'), 'Show Changeset' => pht('Show Changeset'), + + 'You must select source text to create a new inline comment.' => + pht('You must select source text to create a new inline comment.'), + + 'New Inline Comment' => pht('New Inline Comment'), + + 'Add new inline comment on selected source text.' => + pht('Add new inline comment on selected source text.'), ), )); if ($this->header) { $header = $this->header; } else { $header = id(new PHUIHeaderView()) ->setHeader($this->getTitle()); } $content = phutil_tag( 'div', array( 'class' => 'differential-review-stage', 'id' => 'differential-review-stage', ), $output); $object_box = id(new PHUIObjectBoxView()) ->setHeader($header) ->setBackground($this->background) ->setCollapsed(true) ->appendChild($content); return $object_box; } private function renderViewOptionsDropdown( DifferentialChangesetDetailView $detail, $ref, DifferentialChangeset $changeset) { $viewer = $this->getViewer(); $meta = array(); $qparams = array( 'ref' => $ref, ); if ($this->standaloneURI) { $uri = new PhutilURI($this->standaloneURI); $uri = $this->appendDefaultQueryParams($uri, $qparams); $meta['standaloneURI'] = (string)$uri; } $change = $changeset->getChangeType(); if ($this->leftRawFileURI) { if ($change != DifferentialChangeType::TYPE_ADD) { $uri = new PhutilURI($this->leftRawFileURI); $uri = $this->appendDefaultQueryParams($uri, $qparams); $meta['leftURI'] = (string)$uri; } } if ($this->rightRawFileURI) { if ($change != DifferentialChangeType::TYPE_DELETE && $change != DifferentialChangeType::TYPE_MULTICOPY) { $uri = new PhutilURI($this->rightRawFileURI); $uri = $this->appendDefaultQueryParams($uri, $qparams); $meta['rightURI'] = (string)$uri; } } $meta['containerID'] = $detail->getID(); return id(new PHUIButtonView()) ->setTag('a') ->setText(pht('View Options')) ->setIcon('fa-bars') ->setColor(PHUIButtonView::GREY) ->setHref(idx($meta, 'detailURI', '#')) ->setMetadata($meta) ->addSigil('differential-view-options'); } private function appendDefaultQueryParams(PhutilURI $uri, array $params) { // Add these default query parameters to the query string if they do not // already exist. $have = array(); foreach ($uri->getQueryParamsAsPairList() as $pair) { list($key, $value) = $pair; $have[$key] = true; } foreach ($params as $key => $value) { if (!isset($have[$key])) { $uri->appendQueryParam($key, $value); } } return $uri; } } diff --git a/src/applications/files/diff/PhabricatorDocumentEngineBlocks.php b/src/applications/files/diff/PhabricatorDocumentEngineBlocks.php index 2847b53c0d..d07f341815 100644 --- a/src/applications/files/diff/PhabricatorDocumentEngineBlocks.php +++ b/src/applications/files/diff/PhabricatorDocumentEngineBlocks.php @@ -1,175 +1,251 @@ rangeMin = $min; + $this->rangeMax = $max; + return $this; + } + + public function setRevealedIndexes(array $indexes) { + $this->revealedIndexes = $indexes; + return $this; + } + + public function getLayoutAvailableRowCount() { + if ($this->layoutAvailableRowCount === null) { + throw new PhutilInvalidStateException('new...Layout'); + } + + return $this->layoutAvailableRowCount; + } public function addMessage($message) { $this->messages[] = $message; return $this; } public function getMessages() { return $this->messages; } public function addBlockList( PhabricatorDocumentRef $ref = null, array $blocks = array()) { assert_instances_of($blocks, 'PhabricatorDocumentEngineBlock'); $this->lists[] = array( 'ref' => $ref, 'blocks' => array_values($blocks), ); return $this; } public function getDocumentRefs() { return ipull($this->lists, 'ref'); } public function newTwoUpLayout() { $rows = array(); $lists = $this->lists; if (count($lists) != 2) { return array(); } $specs = array(); foreach ($this->lists as $list) { $specs[] = $this->newDiffSpec($list['blocks']); } $old_map = $specs[0]['map']; $new_map = $specs[1]['map']; $old_list = $specs[0]['list']; $new_list = $specs[1]['list']; $changeset = id(new PhabricatorDifferenceEngine()) ->generateChangesetFromFileContent($old_list, $new_list); $hunk_parser = id(new DifferentialHunkParser()) ->parseHunksForLineData($changeset->getHunks()) ->reparseHunksForSpecialAttributes(); $hunk_parser->generateVisibleBlocksMask(2); $mask = $hunk_parser->getVisibleLinesMask(); $old_lines = $hunk_parser->getOldLines(); $new_lines = $hunk_parser->getNewLines(); $rows = array(); $count = count($old_lines); for ($ii = 0; $ii < $count; $ii++) { $old_line = idx($old_lines, $ii); $new_line = idx($new_lines, $ii); $is_visible = !empty($mask[$ii]); if ($old_line) { $old_hash = rtrim($old_line['text'], "\n"); if (!strlen($old_hash)) { // This can happen when one of the sources has no blocks. $old_block = null; } else { $old_block = array_shift($old_map[$old_hash]); $old_block ->setDifferenceType($old_line['type']) ->setIsVisible($is_visible); } } else { $old_block = null; } if ($new_line) { $new_hash = rtrim($new_line['text'], "\n"); if (!strlen($new_hash)) { $new_block = null; } else { $new_block = array_shift($new_map[$new_hash]); $new_block ->setDifferenceType($new_line['type']) ->setIsVisible($is_visible); } } else { $new_block = null; } // If both lists are empty, we may generate a row which has two empty // blocks. if (!$old_block && !$new_block) { continue; } $rows[] = array( $old_block, $new_block, ); } + $this->layoutAvailableRowCount = count($rows); + + $rows = $this->revealIndexes($rows, true); + $rows = $this->sliceRows($rows); + return $rows; } public function newOneUpLayout() { $rows = array(); $lists = $this->lists; $idx = 0; while (true) { $found_any = false; $row = array(); foreach ($lists as $list) { $blocks = $list['blocks']; $cell = idx($blocks, $idx); if ($cell !== null) { $found_any = true; } if ($cell) { $rows[] = $cell; } } if (!$found_any) { break; } $idx++; } + $this->layoutAvailableRowCount = count($rows); + + $rows = $this->revealIndexes($rows, false); + $rows = $this->sliceRows($rows); + return $rows; } private function newDiffSpec(array $blocks) { $map = array(); $list = array(); foreach ($blocks as $block) { $hash = $block->getDifferenceHash(); if (!isset($map[$hash])) { $map[$hash] = array(); } $map[$hash][] = $block; $list[] = $hash; } return array( 'map' => $map, 'list' => implode("\n", $list)."\n", ); } + private function sliceRows(array $rows) { + $min = $this->rangeMin; + $max = $this->rangeMax; + + if ($min === null && $max === null) { + return $rows; + } + + if ($max === null) { + return array_slice($rows, $min, null, true); + } + + if ($min === null) { + $min = 0; + } + + return array_slice($rows, $min, $max - $min, true); + } + + private function revealIndexes(array $rows, $is_vector) { + if ($this->revealedIndexes === null) { + return $rows; + } + + foreach ($this->revealedIndexes as $index) { + if (!isset($rows[$index])) { + continue; + } + + if ($is_vector) { + foreach ($rows[$index] as $block) { + if ($block !== null) { + $block->setIsVisible(true); + } + } + } else { + $rows[$index]->setIsVisible(true); + } + } + + return $rows; + } + } diff --git a/src/applications/passphrase/controller/PassphraseCredentialViewController.php b/src/applications/passphrase/controller/PassphraseCredentialViewController.php index 6688bef285..a5147295c8 100644 --- a/src/applications/passphrase/controller/PassphraseCredentialViewController.php +++ b/src/applications/passphrase/controller/PassphraseCredentialViewController.php @@ -1,211 +1,210 @@ getViewer(); $id = $request->getURIData('id'); $credential = id(new PassphraseCredentialQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->executeOne(); if (!$credential) { return new Aphront404Response(); } $type = $credential->getImplementation(); $timeline = $this->buildTransactionTimeline( $credential, new PassphraseCredentialTransactionQuery()); $timeline->setShouldTerminate(true); $title = pht('%s %s', $credential->getMonogram(), $credential->getName()); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb($credential->getMonogram()); $crumbs->setBorder(true); $header = $this->buildHeaderView($credential); $curtain = $this->buildCurtain($credential, $type); $subheader = $this->buildSubheaderView($credential); $content = $this->buildPropertySectionView($credential, $type); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setSubheader($subheader) ->setCurtain($curtain) ->setMainColumn($timeline) ->addPropertySection(pht('Properties'), $content); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild($view); } private function buildHeaderView(PassphraseCredential $credential) { $viewer = $this->getRequest()->getUser(); $header = id(new PHUIHeaderView()) ->setUser($viewer) ->setHeader($credential->getName()) ->setPolicyObject($credential) ->setHeaderIcon('fa-user-secret'); if ($credential->getIsDestroyed()) { $header->setStatus('fa-ban', 'red', pht('Destroyed')); } return $header; } private function buildSubheaderView( PassphraseCredential $credential) { $viewer = $this->getViewer(); $author = $viewer->renderHandle($credential->getAuthorPHID())->render(); $date = phabricator_datetime($credential->getDateCreated(), $viewer); $author = phutil_tag('strong', array(), $author); $person = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withPHIDs(array($credential->getAuthorPHID())) ->needProfileImage(true) ->executeOne(); if (!$person) { return null; } $image_uri = $person->getProfileImageURI(); $image_href = '/p/'.$credential->getUsername(); $content = pht('Created by %s on %s.', $author, $date); return id(new PHUIHeadThingView()) ->setImage($image_uri) ->setImageHref($image_href) ->setContent($content); } private function buildCurtain( PassphraseCredential $credential, PassphraseCredentialType $type) { $viewer = $this->getViewer(); $id = $credential->getID(); $is_locked = $credential->getIsLocked(); if ($is_locked) { $credential_lock_text = pht('Locked Permanently'); $credential_lock_icon = 'fa-lock'; } else { $credential_lock_text = pht('Lock Permanently'); $credential_lock_icon = 'fa-unlock'; } $allow_conduit = $credential->getAllowConduit(); if ($allow_conduit) { $credential_conduit_text = pht('Prevent Conduit Access'); $credential_conduit_icon = 'fa-ban'; } else { $credential_conduit_text = pht('Allow Conduit Access'); $credential_conduit_icon = 'fa-wrench'; } $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $credential, PhabricatorPolicyCapability::CAN_EDIT); $can_conduit = ($can_edit && !$is_locked); $curtain = $this->newCurtainView($credential); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Credential')) ->setIcon('fa-pencil') ->setHref($this->getApplicationURI("edit/{$id}/")) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); if (!$credential->getIsDestroyed()) { $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Destroy Credential')) ->setIcon('fa-times') ->setHref($this->getApplicationURI("destroy/{$id}/")) ->setDisabled(!$can_edit) ->setWorkflow(true)); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Show Secret')) ->setIcon('fa-eye') ->setHref($this->getApplicationURI("reveal/{$id}/")) ->setDisabled(!$can_edit || $is_locked) ->setWorkflow(true)); if ($type->hasPublicKey()) { $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Show Public Key')) ->setIcon('fa-download') ->setHref($this->getApplicationURI("public/{$id}/")) - ->setDisabled(!$can_edit) ->setWorkflow(true)); } $curtain->addAction( id(new PhabricatorActionView()) ->setName($credential_conduit_text) ->setIcon($credential_conduit_icon) ->setHref($this->getApplicationURI("conduit/{$id}/")) ->setDisabled(!$can_conduit) ->setWorkflow(true)); $curtain->addAction( id(new PhabricatorActionView()) ->setName($credential_lock_text) ->setIcon($credential_lock_icon) ->setHref($this->getApplicationURI("lock/{$id}/")) ->setDisabled(!$can_edit || $is_locked) ->setWorkflow(true)); } return $curtain; } private function buildPropertySectionView( PassphraseCredential $credential, PassphraseCredentialType $type) { $viewer = $this->getRequest()->getUser(); $properties = id(new PHUIPropertyListView()) ->setUser($viewer); $properties->addProperty( pht('Credential Type'), $type->getCredentialTypeName()); if ($type->shouldRequireUsername()) { $properties->addProperty( pht('Username'), $credential->getUsername()); } $description = $credential->getDescription(); if (strlen($description)) { $properties->addSectionHeader( pht('Description'), PHUIPropertyListView::ICON_SUMMARY); $properties->addTextContent( new PHUIRemarkupView($viewer, $description)); } return $properties; } } diff --git a/src/applications/system/controller/PhabricatorSystemSelectViewAsController.php b/src/applications/system/controller/PhabricatorSystemSelectViewAsController.php index 19f800f820..19e33dd512 100644 --- a/src/applications/system/controller/PhabricatorSystemSelectViewAsController.php +++ b/src/applications/system/controller/PhabricatorSystemSelectViewAsController.php @@ -1,63 +1,70 @@ getViewer(); $v_engine = $request->getStr('engine'); if ($request->isFormPost()) { $result = array('engine' => $v_engine); return id(new AphrontAjaxResponse())->setContent($result); } $engines = PhabricatorDocumentEngine::getAllEngines(); + $options = $request->getStrList('options'); + $options = array_fuse($options); - // TODO: This controller isn't very good because the valid options depend - // on the file being rendered and most of them can't even diff anything, - // and this ref is completely bogus. + // TODO: This controller is a bit rough because it isn't really using the + // file ref to figure out which engines should work. See also T13513. + // Callers can pass a list of "options" to control which options are + // presented, at least. - // For now, we just show everything. $ref = new PhabricatorDocumentRef(); $map = array(); foreach ($engines as $engine) { $key = $engine->getDocumentEngineKey(); + + if ($options && !isset($options[$key])) { + continue; + } + $label = $engine->getViewAsLabel($ref); if (!strlen($label)) { continue; } $map[$key] = $label; } asort($map); $map = array( '' => pht('(Use Default)'), ) + $map; $form = id(new AphrontFormView()) ->setViewer($viewer) ->appendRemarkupInstructions(pht('Choose a document engine to use.')) ->appendChild( id(new AphrontFormSelectControl()) ->setLabel(pht('View As')) ->setName('engine') ->setValue($v_engine) ->setOptions($map)); return $this->newDialog() ->setTitle(pht('Select Document Engine')) ->appendForm($form) ->addSubmitButton(pht('Choose Engine')) ->addCancelButton('/'); } } diff --git a/src/applications/transactions/storage/PhabricatorApplicationTransactionComment.php b/src/applications/transactions/storage/PhabricatorApplicationTransactionComment.php index 3239125249..896b45556a 100644 --- a/src/applications/transactions/storage/PhabricatorApplicationTransactionComment.php +++ b/src/applications/transactions/storage/PhabricatorApplicationTransactionComment.php @@ -1,177 +1,182 @@ true, self::CONFIG_COLUMN_SCHEMA => array( 'transactionPHID' => 'phid?', 'commentVersion' => 'uint32', 'content' => 'text', 'contentSource' => 'text', 'isDeleted' => 'bool', ), self::CONFIG_KEY_SCHEMA => array( 'key_version' => array( 'columns' => array('transactionPHID', 'commentVersion'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function getApplicationName() { return $this->getApplicationTransactionObject()->getApplicationName(); } public function getTableName() { $xaction = $this->getApplicationTransactionObject(); return self::getTableNameFromTransaction($xaction); } public static function getTableNameFromTransaction( PhabricatorApplicationTransaction $xaction) { return $xaction->getTableName().'_comment'; } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source->serialize(); return $this; } public function setContentSourceFromRequest(AphrontRequest $request) { return $this->setContentSource( PhabricatorContentSource::newFromRequest($request)); } public function getContentSource() { return PhabricatorContentSource::newFromSerialized($this->contentSource); } public function getIsRemoved() { return ($this->getIsDeleted() == 2); } public function setIsRemoved($removed) { if ($removed) { $this->setIsDeleted(2); } else { $this->setIsDeleted(0); } return $this; } public function attachOldComment( PhabricatorApplicationTransactionComment $old_comment) { $this->oldComment = $old_comment; return $this; } public function getOldComment() { return $this->assertAttached($this->oldComment); } public function hasOldComment() { return ($this->oldComment !== self::ATTACHABLE); } + public function getRawRemarkupURI() { + return urisprintf( + '/transactions/raw/%s/', + $this->getTransactionPHID()); + } /* -( PhabricatorMarkupInterface )----------------------------------------- */ public function getMarkupFieldKey($field) { return PhabricatorPHIDConstants::PHID_TYPE_XCMT.':'.$this->getPHID(); } public function newMarkupEngine($field) { return PhabricatorMarkupEngine::getEngine(); } public function getMarkupText($field) { return $this->getContent(); } public function didMarkupText($field, $output, PhutilMarkupEngine $engine) { require_celerity_resource('phabricator-remarkup-css'); return phutil_tag( 'div', array( 'class' => 'phabricator-remarkup', ), $output); } public function shouldUseMarkupCache($field) { return (bool)$this->getPHID(); } /* -( PhabricatorPolicyInterface Implementation )-------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return ($viewer->getPHID() == $this->getAuthorPHID()); } public function describeAutomaticCapability($capability) { return pht( 'Comments are visible to users who can see the object which was '. 'commented on. Comments can be edited by their authors.'); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $this->saveTransaction(); } } diff --git a/src/infrastructure/diff/PhabricatorInlineCommentController.php b/src/infrastructure/diff/PhabricatorInlineCommentController.php index 529bd34c6e..4d4b1c0d2c 100644 --- a/src/infrastructure/diff/PhabricatorInlineCommentController.php +++ b/src/infrastructure/diff/PhabricatorInlineCommentController.php @@ -1,558 +1,563 @@ containerObject === null) { $object = $this->newContainerObject(); if (!$object) { throw new Exception( pht( 'Failed to load container object for inline comment.')); } $this->containerObject = $object; } return $this->containerObject; } protected function hideComments(array $ids) { throw new PhutilMethodNotImplementedException(); } protected function showComments(array $ids) { throw new PhutilMethodNotImplementedException(); } private $changesetID; private $isNewFile; private $isOnRight; private $lineNumber; private $lineLength; private $commentText; private $operation; private $commentID; private $renderer; private $replyToCommentPHID; public function getCommentID() { return $this->commentID; } public function getOperation() { return $this->operation; } public function getCommentText() { return $this->commentText; } public function getLineLength() { return $this->lineLength; } public function getLineNumber() { return $this->lineNumber; } public function getIsOnRight() { return $this->isOnRight; } public function getChangesetID() { return $this->changesetID; } public function getIsNewFile() { return $this->isNewFile; } public function setRenderer($renderer) { $this->renderer = $renderer; return $this; } public function getRenderer() { return $this->renderer; } public function setReplyToCommentPHID($phid) { $this->replyToCommentPHID = $phid; return $this; } public function getReplyToCommentPHID() { return $this->replyToCommentPHID; } public function processRequest() { $request = $this->getRequest(); $viewer = $this->getViewer(); $this->readRequestParameters(); $op = $this->getOperation(); switch ($op) { case 'hide': case 'show': if (!$request->validateCSRF()) { return new Aphront404Response(); } $ids = $request->getStrList('ids'); if ($ids) { if ($op == 'hide') { $this->hideComments($ids); } else { $this->showComments($ids); } } return id(new AphrontAjaxResponse())->setContent(array()); case 'done': if (!$request->validateCSRF()) { return new Aphront404Response(); } $inline = $this->loadCommentForDone($this->getCommentID()); $is_draft_state = false; $is_checked = false; switch ($inline->getFixedState()) { case PhabricatorInlineComment::STATE_DRAFT: $next_state = PhabricatorInlineComment::STATE_UNDONE; break; case PhabricatorInlineComment::STATE_UNDRAFT: $next_state = PhabricatorInlineComment::STATE_DONE; $is_checked = true; break; case PhabricatorInlineComment::STATE_DONE: $next_state = PhabricatorInlineComment::STATE_UNDRAFT; $is_draft_state = true; break; default: case PhabricatorInlineComment::STATE_UNDONE: $next_state = PhabricatorInlineComment::STATE_DRAFT; $is_draft_state = true; $is_checked = true; break; } $inline->setFixedState($next_state)->save(); return id(new AphrontAjaxResponse()) ->setContent( array( 'isChecked' => $is_checked, 'draftState' => $is_draft_state, )); case 'delete': case 'undelete': case 'refdelete': if (!$request->validateCSRF()) { return new Aphront404Response(); } // NOTE: For normal deletes, we just process the delete immediately // and show an "Undo" action. For deletes by reference from the // preview ("refdelete"), we prompt first (because the "Undo" may // not draw, or may not be easy to locate). if ($op == 'refdelete') { if (!$request->isFormPost()) { return $this->newDialog() ->setTitle(pht('Really delete comment?')) ->addHiddenInput('id', $this->getCommentID()) ->addHiddenInput('op', $op) ->appendParagraph(pht('Delete this inline comment?')) ->addCancelButton('#') ->addSubmitButton(pht('Delete')); } } $is_delete = ($op == 'delete' || $op == 'refdelete'); $inline = $this->loadCommentByIDForEdit($this->getCommentID()); if ($is_delete) { $inline->setIsDeleted(1); } else { $inline->setIsDeleted(0); } $this->saveComment($inline); return $this->buildEmptyResponse(); case 'edit': $inline = $this->loadCommentByIDForEdit($this->getCommentID()); $text = $this->getCommentText(); if ($request->isFormPost()) { if (strlen($text)) { $inline ->setContent($text) ->setIsEditing(false); $this->saveComment($inline); return $this->buildRenderedCommentResponse( $inline, $this->getIsOnRight()); } else { $inline->setIsDeleted(1); $this->saveComment($inline); return $this->buildEmptyResponse(); } } else { // NOTE: At time of writing, the "editing" state of inlines is // preserved by simluating a click on "Edit" when the inline loads. // In this case, we don't want to "saveComment()", because it // recalculates object drafts and purges versioned drafts. // The recalculation is merely unnecessary (state doesn't change) // but purging drafts means that loading a page and then closing it // discards your drafts. // To avoid the purge, only invoke "saveComment()" if we actually // have changes to apply. $is_dirty = false; if (!$inline->getIsEditing()) { $inline->setIsEditing(true); $is_dirty = true; } if (strlen($text)) { $inline->setContent($text); $is_dirty = true; } else { PhabricatorInlineComment::loadAndAttachVersionedDrafts( $viewer, array($inline)); } if ($is_dirty) { $this->saveComment($inline); } } $edit_dialog = $this->buildEditDialog($inline) ->setTitle(pht('Edit Inline Comment')); $view = $this->buildScaffoldForView($edit_dialog); return $this->newInlineResponse($inline, $view); case 'cancel': $inline = $this->loadCommentByIDForEdit($this->getCommentID()); $inline->setIsEditing(false); // If the user uses "Undo" to get into an edited state ("AB"), then // clicks cancel to return to the previous state ("A"), we also want // to set the stored state back to "A". $text = $this->getCommentText(); if (strlen($text)) { $inline->setContent($text); } $content = $inline->getContent(); if (!strlen($content)) { $inline->setIsDeleted(1); } $this->saveComment($inline); return $this->buildEmptyResponse(); case 'draft': $inline = $this->loadCommentByIDForEdit($this->getCommentID()); $versioned_draft = PhabricatorVersionedDraft::loadOrCreateDraft( $inline->getPHID(), $viewer->getPHID(), $inline->getID()); $text = $this->getCommentText(); $versioned_draft ->setProperty('inline.text', $text) ->save(); // We have to synchronize the draft engine after saving a versioned // draft, because taking an inline comment from "no text, no draft" // to "no text, text in a draft" marks the container object as having // a draft. $draft_engine = $this->newDraftEngine(); if ($draft_engine) { $draft_engine->synchronize(); - } else { - phlog('no draft engine'); } return $this->buildEmptyResponse(); case 'new': case 'reply': default: // NOTE: We read the values from the client (the display values), not // the values from the database (the original values) when replying. // In particular, when replying to a ghost comment which was moved // across diffs and then moved backward to the most recent visible // line, we want to reply on the display line (which exists), not on // the comment's original line (which may not exist in this changeset). $is_new = $this->getIsNewFile(); $number = $this->getLineNumber(); $length = $this->getLineLength(); $inline = $this->createComment() ->setChangesetID($this->getChangesetID()) ->setAuthorPHID($viewer->getPHID()) ->setIsNewFile($is_new) ->setLineNumber($number) ->setLineLength($length) - ->setContent($this->getCommentText()) + ->setContent((string)$this->getCommentText()) ->setReplyToCommentPHID($this->getReplyToCommentPHID()) - ->setIsEditing(true); + ->setIsEditing(true) + ->setStartOffset($request->getInt('startOffset')) + ->setEndOffset($request->getInt('endOffset')); + + $document_engine_key = $request->getStr('documentEngineKey'); + if ($document_engine_key !== null) { + $inline->setDocumentEngineKey($document_engine_key); + } // If you own this object, mark your own inlines as "Done" by default. $owner_phid = $this->loadObjectOwnerPHID($inline); if ($owner_phid) { if ($viewer->getPHID() == $owner_phid) { $fixed_state = PhabricatorInlineComment::STATE_DRAFT; $inline->setFixedState($fixed_state); } } $this->saveComment($inline); $edit_dialog = $this->buildEditDialog($inline); if ($this->getOperation() == 'reply') { $edit_dialog->setTitle(pht('Reply to Inline Comment')); } else { $edit_dialog->setTitle(pht('New Inline Comment')); } $view = $this->buildScaffoldForView($edit_dialog); return $this->newInlineResponse($inline, $view); } } private function readRequestParameters() { $request = $this->getRequest(); // NOTE: This isn't necessarily a DifferentialChangeset ID, just an // application identifier for the changeset. In Diffusion, it's a Path ID. $this->changesetID = $request->getInt('changesetID'); $this->isNewFile = (int)$request->getBool('is_new'); $this->isOnRight = $request->getBool('on_right'); $this->lineNumber = $request->getInt('number'); $this->lineLength = $request->getInt('length'); $this->commentText = $request->getStr('text'); $this->commentID = $request->getInt('id'); $this->operation = $request->getStr('op'); $this->renderer = $request->getStr('renderer'); $this->replyToCommentPHID = $request->getStr('replyToCommentPHID'); if ($this->getReplyToCommentPHID()) { $reply_phid = $this->getReplyToCommentPHID(); $reply_comment = $this->loadCommentByPHID($reply_phid); if (!$reply_comment) { throw new Exception( pht('Failed to load comment "%s".', $reply_phid)); } // When replying, force the new comment into the same location as the // old comment. If we don't do this, replying to a ghost comment from // diff A while viewing diff B can end up placing the two comments in // different places while viewing diff C, because the porting algorithm // makes a different decision. Forcing the comments to bind to the same // place makes sure they stick together no matter which diff is being // viewed. See T10562 for discussion. $this->changesetID = $reply_comment->getChangesetID(); $this->isNewFile = $reply_comment->getIsNewFile(); $this->lineNumber = $reply_comment->getLineNumber(); $this->lineLength = $reply_comment->getLineLength(); } } private function buildEditDialog(PhabricatorInlineComment $inline) { $request = $this->getRequest(); $viewer = $this->getViewer(); $edit_dialog = id(new PHUIDiffInlineCommentEditView()) ->setViewer($viewer) ->setInlineComment($inline) ->setIsOnRight($this->getIsOnRight()) ->setRenderer($this->getRenderer()); return $edit_dialog; } private function buildEmptyResponse() { return id(new AphrontAjaxResponse()) ->setContent( array( 'inline' => array(), 'view' => null, )); } private function buildRenderedCommentResponse( PhabricatorInlineComment $inline, $on_right) { $request = $this->getRequest(); $viewer = $this->getViewer(); $engine = new PhabricatorMarkupEngine(); $engine->setViewer($viewer); $engine->addObject( $inline, PhabricatorInlineComment::MARKUP_FIELD_BODY); $engine->process(); $phids = array($viewer->getPHID()); $handles = $this->loadViewerHandles($phids); $object_owner_phid = $this->loadObjectOwnerPHID($inline); $view = id(new PHUIDiffInlineCommentDetailView()) ->setUser($viewer) ->setInlineComment($inline) ->setIsOnRight($on_right) ->setMarkupEngine($engine) ->setHandles($handles) ->setEditable(true) ->setCanMarkDone(false) ->setObjectOwnerPHID($object_owner_phid); $view = $this->buildScaffoldForView($view); return $this->newInlineResponse($inline, $view); } private function buildScaffoldForView(PHUIDiffInlineCommentView $view) { $renderer = DifferentialChangesetHTMLRenderer::getHTMLRendererByKey( $this->getRenderer()); $view = $renderer->getRowScaffoldForInline($view); return id(new PHUIDiffInlineCommentTableScaffold()) ->addRowScaffold($view); } private function newInlineResponse( PhabricatorInlineComment $inline, $view) { $response = array( 'inline' => array( 'id' => $inline->getID(), ), 'view' => hsprintf('%s', $view), ); return id(new AphrontAjaxResponse()) ->setContent($response); } final protected function loadCommentByID($id) { $query = $this->newInlineCommentQuery() ->withIDs(array($id)); return $this->loadCommentByQuery($query); } final protected function loadCommentByPHID($phid) { $query = $this->newInlineCommentQuery() ->withPHIDs(array($phid)); return $this->loadCommentByQuery($query); } final protected function loadCommentByIDForEdit($id) { $viewer = $this->getViewer(); $query = $this->newInlineCommentQuery() ->withIDs(array($id)); $inline = $this->loadCommentByQuery($query); if (!$inline) { throw new Exception( pht( 'Unable to load inline "%s".', $id)); } if (!$this->canEditInlineComment($viewer, $inline)) { throw new Exception( pht( 'Inline comment "%s" is not editable.', $id)); } return $inline; } private function loadCommentByQuery( PhabricatorDiffInlineCommentQuery $query) { $viewer = $this->getViewer(); $inline = $query ->setViewer($viewer) ->executeOne(); if ($inline) { $inline = $inline->newInlineCommentObject(); } return $inline; } private function saveComment(PhabricatorInlineComment $inline) { $viewer = $this->getViewer(); $draft_engine = $this->newDraftEngine(); $inline->openTransaction(); $inline->save(); PhabricatorVersionedDraft::purgeDrafts( $inline->getPHID(), $viewer->getPHID()); if ($draft_engine) { $draft_engine->synchronize(); } $inline->saveTransaction(); } private function newDraftEngine() { $viewer = $this->getViewer(); $object = $this->getContainerObject(); if (!($object instanceof PhabricatorDraftInterface)) { return null; } return $object->newDraftEngine() ->setObject($object) ->setViewer($viewer); } } diff --git a/src/infrastructure/diff/interface/PhabricatorInlineComment.php b/src/infrastructure/diff/interface/PhabricatorInlineComment.php index 50079589fc..ae0b950544 100644 --- a/src/infrastructure/diff/interface/PhabricatorInlineComment.php +++ b/src/infrastructure/diff/interface/PhabricatorInlineComment.php @@ -1,323 +1,350 @@ storageObject = clone $this->storageObject; } final public static function loadAndAttachVersionedDrafts( PhabricatorUser $viewer, array $inlines) { $viewer_phid = $viewer->getPHID(); if (!$viewer_phid) { return; } $inlines = mpull($inlines, null, 'getPHID'); $load = array(); foreach ($inlines as $key => $inline) { if (!$inline->getIsEditing()) { continue; } if ($inline->getAuthorPHID() !== $viewer_phid) { continue; } $load[$key] = $inline; } if (!$load) { return; } $drafts = PhabricatorVersionedDraft::loadDrafts( array_keys($load), $viewer_phid); $drafts = mpull($drafts, null, 'getObjectPHID'); foreach ($inlines as $inline) { $draft = idx($drafts, $inline->getPHID()); $inline->attachVersionedDraftForViewer($viewer, $draft); } } public function setSyntheticAuthor($synthetic_author) { $this->syntheticAuthor = $synthetic_author; return $this; } public function getSyntheticAuthor() { return $this->syntheticAuthor; } public function setStorageObject($storage_object) { $this->storageObject = $storage_object; return $this; } public function getStorageObject() { if (!$this->storageObject) { $this->storageObject = $this->newStorageObject(); } return $this->storageObject; } abstract protected function newStorageObject(); abstract public function getControllerURI(); abstract public function setChangesetID($id); abstract public function getChangesetID(); abstract public function supportsHiding(); abstract public function isHidden(); public function isDraft() { return !$this->getTransactionPHID(); } public function getTransactionPHID() { return $this->getStorageObject()->getTransactionPHID(); } public function isCompatible(PhabricatorInlineComment $comment) { return ($this->getAuthorPHID() === $comment->getAuthorPHID()) && ($this->getSyntheticAuthor() === $comment->getSyntheticAuthor()) && ($this->getContent() === $comment->getContent()); } public function setIsGhost($is_ghost) { $this->isGhost = $is_ghost; return $this; } public function getIsGhost() { return $this->isGhost; } public function setContent($content) { $this->getStorageObject()->setContent($content); return $this; } public function getContent() { return $this->getStorageObject()->getContent(); } public function getID() { return $this->getStorageObject()->getID(); } public function getPHID() { return $this->getStorageObject()->getPHID(); } public function setIsNewFile($is_new) { $this->getStorageObject()->setIsNewFile($is_new); return $this; } public function getIsNewFile() { return $this->getStorageObject()->getIsNewFile(); } public function setFixedState($state) { $this->getStorageObject()->setFixedState($state); return $this; } public function setHasReplies($has_replies) { $this->getStorageObject()->setHasReplies($has_replies); return $this; } public function getHasReplies() { return $this->getStorageObject()->getHasReplies(); } public function getFixedState() { return $this->getStorageObject()->getFixedState(); } public function setLineNumber($number) { $this->getStorageObject()->setLineNumber($number); return $this; } public function getLineNumber() { return $this->getStorageObject()->getLineNumber(); } public function setLineLength($length) { $this->getStorageObject()->setLineLength($length); return $this; } public function getLineLength() { return $this->getStorageObject()->getLineLength(); } public function setAuthorPHID($phid) { $this->getStorageObject()->setAuthorPHID($phid); return $this; } public function getAuthorPHID() { return $this->getStorageObject()->getAuthorPHID(); } public function setReplyToCommentPHID($phid) { $this->getStorageObject()->setReplyToCommentPHID($phid); return $this; } public function getReplyToCommentPHID() { return $this->getStorageObject()->getReplyToCommentPHID(); } public function setIsDeleted($is_deleted) { $this->getStorageObject()->setIsDeleted($is_deleted); return $this; } public function getIsDeleted() { return $this->getStorageObject()->getIsDeleted(); } public function setIsEditing($is_editing) { $this->getStorageObject()->setAttribute('editing', (bool)$is_editing); return $this; } public function getIsEditing() { return (bool)$this->getStorageObject()->getAttribute('editing', false); } + public function setDocumentEngineKey($engine_key) { + $this->getStorageObject()->setAttribute('documentEngineKey', $engine_key); + return $this; + } + + public function getDocumentEngineKey() { + return $this->getStorageObject()->getAttribute('documentEngineKey'); + } + + public function setStartOffset($offset) { + $this->getStorageObject()->setAttribute('startOffset', $offset); + return $this; + } + + public function getStartOffset() { + return $this->getStorageObject()->getAttribute('startOffset'); + } + + public function setEndOffset($offset) { + $this->getStorageObject()->setAttribute('endOffset', $offset); + return $this; + } + + public function getEndOffset() { + return $this->getStorageObject()->getAttribute('endOffset'); + } + public function getDateModified() { return $this->getStorageObject()->getDateModified(); } public function getDateCreated() { return $this->getStorageObject()->getDateCreated(); } public function openTransaction() { $this->getStorageObject()->openTransaction(); } public function saveTransaction() { $this->getStorageObject()->saveTransaction(); } public function save() { $this->getTransactionCommentForSave()->save(); return $this; } public function delete() { $this->getStorageObject()->delete(); return $this; } public function makeEphemeral() { $this->getStorageObject()->makeEphemeral(); return $this; } public function attachVersionedDraftForViewer( PhabricatorUser $viewer, PhabricatorVersionedDraft $draft = null) { $key = $viewer->getCacheFragment(); $this->versionedDrafts[$key] = $draft; return $this; } public function hasVersionedDraftForViewer(PhabricatorUser $viewer) { $key = $viewer->getCacheFragment(); return array_key_exists($key, $this->versionedDrafts); } public function getVersionedDraftForViewer(PhabricatorUser $viewer) { $key = $viewer->getCacheFragment(); if (!array_key_exists($key, $this->versionedDrafts)) { throw new Exception( pht( 'Versioned draft is not attached for user with fragment "%s".', $key)); } return $this->versionedDrafts[$key]; } public function isVoidComment(PhabricatorUser $viewer) { return !strlen($this->getContentForEdit($viewer)); } public function getContentForEdit(PhabricatorUser $viewer) { $content = $this->getContent(); if (!$this->hasVersionedDraftForViewer($viewer)) { return $content; } $versioned_draft = $this->getVersionedDraftForViewer($viewer); if (!$versioned_draft) { return $content; } $draft_text = $versioned_draft->getProperty('inline.text'); if ($draft_text === null) { return $content; } return $draft_text; } /* -( PhabricatorMarkupInterface Implementation )-------------------------- */ public function getMarkupFieldKey($field) { $content = $this->getMarkupText($field); return PhabricatorMarkupEngine::digestRemarkupContent($this, $content); } public function newMarkupEngine($field) { return PhabricatorMarkupEngine::newDifferentialMarkupEngine(); } public function getMarkupText($field) { return $this->getContent(); } public function didMarkupText($field, $output, PhutilMarkupEngine $engine) { return $output; } public function shouldUseMarkupCache($field) { return !$this->isDraft(); } } diff --git a/src/infrastructure/diff/view/PHUIDiffInlineCommentDetailView.php b/src/infrastructure/diff/view/PHUIDiffInlineCommentDetailView.php index 77b28bb336..900491e00b 100644 --- a/src/infrastructure/diff/view/PHUIDiffInlineCommentDetailView.php +++ b/src/infrastructure/diff/view/PHUIDiffInlineCommentDetailView.php @@ -1,468 +1,495 @@ getInlineComment()->isHidden(); } public function setHandles(array $handles) { assert_instances_of($handles, 'PhabricatorObjectHandle'); $this->handles = $handles; return $this; } public function setMarkupEngine(PhabricatorMarkupEngine $engine) { $this->markupEngine = $engine; return $this; } public function setEditable($editable) { $this->editable = $editable; return $this; } public function setPreview($preview) { $this->preview = $preview; return $this; } public function setAllowReply($allow_reply) { $this->allowReply = $allow_reply; return $this; } public function setCanMarkDone($can_mark_done) { $this->canMarkDone = $can_mark_done; return $this; } public function getCanMarkDone() { return $this->canMarkDone; } public function setObjectOwnerPHID($phid) { $this->objectOwnerPHID = $phid; return $this; } public function getObjectOwnerPHID() { return $this->objectOwnerPHID; } public function getAnchorName() { $inline = $this->getInlineComment(); if ($inline->getID()) { return 'inline-'.$inline->getID(); } return null; } public function getScaffoldCellID() { $anchor = $this->getAnchorName(); if ($anchor) { return 'anchor-'.$anchor; } return null; } public function render() { require_celerity_resource('phui-inline-comment-view-css'); $inline = $this->getInlineComment(); - $classes = array( - 'differential-inline-comment', - ); - $is_synthetic = false; if ($inline->getSyntheticAuthor()) { $is_synthetic = true; } - $metadata = $this->getInlineCommentMetadata(); + $is_preview = $this->preview; - $sigil = 'differential-inline-comment'; - if ($this->preview) { - $sigil = $sigil.' differential-inline-comment-preview'; - } + $metadata = $this->getInlineCommentMetadata(); $classes = array( 'differential-inline-comment', ); + $sigil = 'differential-inline-comment'; + if ($is_preview) { + $sigil = $sigil.' differential-inline-comment-preview'; + + $classes[] = 'inline-comment-preview'; + } else { + $classes[] = 'inline-comment-element'; + } + $content = $inline->getContent(); $handles = $this->handles; $links = array(); $draft_text = null; if (!$is_synthetic) { // This display is controlled by CSS $draft_text = id(new PHUITagView()) ->setType(PHUITagView::TYPE_SHADE) ->setName(pht('Unsubmitted')) ->setSlimShady(true) ->setColor(PHUITagView::COLOR_RED) ->addClass('mml inline-draft-text'); } $ghost_tag = null; $ghost = $inline->getIsGhost(); $ghost_id = null; if ($ghost) { if ($ghost['new']) { $ghosticon = 'fa-fast-forward'; $reason = pht('View on forward revision'); } else { $ghosticon = 'fa-fast-backward'; $reason = pht('View on previous revision'); } $ghost_icon = id(new PHUIIconView()) ->setIcon($ghosticon) ->addSigil('has-tooltip') ->setMetadata( array( 'tip' => $reason, 'size' => 300, )); $ghost_tag = phutil_tag( 'a', array( 'class' => 'ghost-icon', 'href' => $ghost['href'], 'target' => '_blank', ), $ghost_icon); $classes[] = 'inline-comment-ghost'; } - // I think this is unused - if ($inline->getHasReplies()) { - $classes[] = 'inline-comment-has-reply'; - } - if ($inline->getReplyToCommentPHID()) { $classes[] = 'inline-comment-is-reply'; } $viewer_phid = $this->getUser()->getPHID(); $owner_phid = $this->getObjectOwnerPHID(); if ($viewer_phid) { if ($viewer_phid == $owner_phid) { $classes[] = 'viewer-is-object-owner'; } } $anchor_name = $this->getAnchorName(); $action_buttons = array(); - - $can_reply = - (!$this->editable) && - (!$this->preview) && - ($this->allowReply) && - - // NOTE: No product reason why you can't reply to synthetic comments, - // but the reply mechanism currently sends the inline comment ID to the - // server, not file/line information, and synthetic comments don't have - // an inline comment ID. - (!$is_synthetic); - - - if ($can_reply) { - $action_buttons[] = id(new PHUIButtonView()) - ->setTag('a') - ->setIcon('fa-reply') - ->setTooltip(pht('Reply')) - ->addSigil('differential-inline-reply') - ->setMustCapture(true) - ->setAuralLabel(pht('Reply')); - } - - if ($this->editable && !$this->preview) { - $action_buttons[] = id(new PHUIButtonView()) - ->setTag('a') - ->setIcon('fa-pencil') - ->setTooltip(pht('Edit')) - ->addSigil('differential-inline-edit') - ->setMustCapture(true) - ->setAuralLabel(pht('Edit')); - - $action_buttons[] = id(new PHUIButtonView()) - ->setTag('a') - ->setIcon('fa-trash-o') - ->setTooltip(pht('Delete')) - ->addSigil('differential-inline-delete') - ->setMustCapture(true) - ->setAuralLabel(pht('Delete')); - - } else if ($this->preview) { + $menu_items = array(); + + if ($this->editable && !$is_preview) { + $menu_items[] = array( + 'label' => pht('Edit Comment'), + 'icon' => 'fa-pencil', + 'action' => 'edit', + 'key' => 'e', + ); + } else if ($is_preview) { $links[] = javelin_tag( 'a', array( 'class' => 'inline-button-divider pml msl', 'meta' => array( 'inlineCommentID' => $inline->getID(), ), 'sigil' => 'differential-inline-preview-jump', ), pht('View')); $action_buttons[] = id(new PHUIButtonView()) ->setTag('a') ->setTooltip(pht('Delete')) ->setIcon('fa-trash-o') ->addSigil('differential-inline-delete') ->setMustCapture(true) ->setAuralLabel(pht('Delete')); } - if (!$this->preview && $this->canHide()) { - $action_buttons[] = id(new PHUIButtonView()) - ->setTag('a') - ->setTooltip(pht('Collapse')) - ->setIcon('fa-times') - ->addSigil('hide-inline') - ->setMustCapture(true) - ->setAuralLabel(pht('Collapse')); + if (!$is_preview && $this->canHide()) { + $menu_items[] = array( + 'label' => pht('Collapse'), + 'icon' => 'fa-times', + 'action' => 'collapse', + 'key' => 'q', + ); + } + + $can_reply = + (!$this->editable) && + (!$is_preview) && + ($this->allowReply) && + + // NOTE: No product reason why you can't reply to synthetic comments, + // but the reply mechanism currently sends the inline comment ID to the + // server, not file/line information, and synthetic comments don't have + // an inline comment ID. + (!$is_synthetic); + + if ($can_reply) { + $menu_items[] = array( + 'label' => pht('Reply to Comment'), + 'icon' => 'fa-reply', + 'action' => 'reply', + 'key' => 'r', + ); + + $menu_items[] = array( + 'label' => pht('Quote Comment'), + 'icon' => 'fa-quote-left', + 'action' => 'quote', + 'key' => 'R', + ); + } + + if (!$is_preview) { + $xaction_phid = $inline->getTransactionPHID(); + $storage = $inline->getStorageObject(); + + if ($xaction_phid) { + $menu_items[] = array( + 'label' => pht('View Raw Remarkup'), + 'icon' => 'fa-code', + 'action' => 'raw', + 'uri' => $storage->getRawRemarkupURI(), + ); + } + } + + if ($this->editable && !$is_preview) { + $menu_items[] = array( + 'label' => pht('Delete Comment'), + 'icon' => 'fa-trash-o', + 'action' => 'delete', + ); } $done_button = null; $mark_done = $this->getCanMarkDone(); // Allow users to mark their own draft inlines as "Done". if ($viewer_phid == $inline->getAuthorPHID()) { if ($inline->isDraft()) { $mark_done = true; } } if (!$is_synthetic) { $draft_state = false; switch ($inline->getFixedState()) { case PhabricatorInlineComment::STATE_DRAFT: $is_done = $mark_done; $draft_state = true; break; case PhabricatorInlineComment::STATE_UNDRAFT: $is_done = !$mark_done; $draft_state = true; break; case PhabricatorInlineComment::STATE_DONE: $is_done = true; break; default: case PhabricatorInlineComment::STATE_UNDONE: $is_done = false; break; } // If you don't have permission to mark the comment as "Done", you also // can not see the draft state. if (!$mark_done) { $draft_state = false; } if ($is_done) { $classes[] = 'inline-is-done'; } if ($draft_state) { $classes[] = 'inline-state-is-draft'; } - if ($mark_done && !$this->preview) { + if ($mark_done && !$is_preview) { $done_input = javelin_tag( 'input', array( 'type' => 'checkbox', 'checked' => ($is_done ? 'checked' : null), 'class' => 'differential-inline-done', 'sigil' => 'differential-inline-done', )); $done_button = phutil_tag( 'label', array( 'class' => 'differential-inline-done-label ', ), array( $done_input, pht('Done'), )); } else { if ($is_done) { $icon = id(new PHUIIconView())->setIcon('fa-check sky msr'); $label = pht('Done'); $class = 'button-done'; } else { $icon = null; $label = pht('Not Done'); $class = 'button-not-done'; } $done_button = phutil_tag( 'div', array( 'class' => 'done-label '.$class, ), array( $icon, $label, )); } } $content = $this->markupEngine->getOutput( $inline, PhabricatorInlineComment::MARKUP_FIELD_BODY); - if ($this->preview) { + if ($is_preview) { $anchor = null; } else { $anchor = phutil_tag( 'a', array( 'name' => $anchor_name, 'id' => $anchor_name, 'class' => 'differential-inline-comment-anchor', ), ''); } if ($inline->isDraft() && !$is_synthetic) { $classes[] = 'inline-state-is-draft'; } if ($is_synthetic) { $classes[] = 'differential-inline-comment-synthetic'; } $classes = implode(' ', $classes); $author_owner = null; if ($is_synthetic) { $author = $inline->getSyntheticAuthor(); } else { $author = $handles[$inline->getAuthorPHID()]->getName(); if ($inline->getAuthorPHID() == $this->objectOwnerPHID) { $author_owner = id(new PHUITagView()) ->setType(PHUITagView::TYPE_SHADE) ->setName(pht('Author')) ->setSlimShady(true) ->setColor(PHUITagView::COLOR_YELLOW) ->addClass('mml'); } } $actions = null; - if ($action_buttons) { + if ($action_buttons || $menu_items) { $actions = new PHUIButtonBarView(); $actions->setBorderless(true); $actions->addClass('inline-button-divider'); foreach ($action_buttons as $button) { $actions->addButton($button); } + + if (!$is_preview) { + $menu_button = id(new PHUIButtonView()) + ->setTag('a') + ->setColor(PHUIButtonView::GREY) + ->setDropdown(true) + ->setAuralLabel(pht('Inline Actions')) + ->addSigil('inline-action-dropdown'); + + $actions->addButton($menu_button); + } } $group_left = phutil_tag( 'div', array( 'class' => 'inline-head-left', ), array( $author, $author_owner, $draft_text, $ghost_tag, )); $group_right = phutil_tag( 'div', array( 'class' => 'inline-head-right', ), array( $done_button, $links, $actions, )); $snippet = id(new PhutilUTF8StringTruncator()) ->setMaximumGlyphs(96) ->truncateString($inline->getContent()); $metadata['snippet'] = pht('%s: %s', $author, $snippet); + $metadata['menuItems'] = $menu_items; + $markup = javelin_tag( 'div', array( 'class' => $classes, 'sigil' => $sigil, 'meta' => $metadata, ), array( javelin_tag( 'div', array( 'class' => 'differential-inline-comment-head grouped', 'sigil' => 'differential-inline-header', ), array( $group_left, $group_right, )), phutil_tag_div( 'differential-inline-comment-content', phutil_tag_div('phabricator-remarkup', $content)), )); $summary = phutil_tag( 'div', array( 'class' => 'differential-inline-summary', ), array( phutil_tag('strong', array(), pht('%s:', $author)), ' ', $snippet, )); return array( $anchor, $markup, $summary, ); } private function canHide() { $inline = $this->getInlineComment(); if ($inline->isDraft()) { return false; } if (!$inline->getID()) { return false; } $viewer = $this->getUser(); if (!$viewer->isLoggedIn()) { return false; } if (!$inline->supportsHiding()) { return false; } return true; } } diff --git a/src/infrastructure/diff/view/PHUIDiffInlineCommentView.php b/src/infrastructure/diff/view/PHUIDiffInlineCommentView.php index 3c3abea400..2481442c1d 100644 --- a/src/infrastructure/diff/view/PHUIDiffInlineCommentView.php +++ b/src/infrastructure/diff/view/PHUIDiffInlineCommentView.php @@ -1,99 +1,102 @@ inlineComment = $comment; return $this; } public function getInlineComment() { return $this->inlineComment; } public function getIsOnRight() { return $this->isOnRight; } public function setIsOnRight($on_right) { $this->isOnRight = $on_right; return $this; } public function setRenderer($renderer) { $this->renderer = $renderer; return $this; } public function getRenderer() { return $this->renderer; } public function getScaffoldCellID() { return null; } public function isHidden() { return false; } public function isHideable() { return true; } public function newHiddenIcon() { if ($this->isHideable()) { return new PHUIDiffRevealIconView(); } else { return null; } } protected function getInlineCommentMetadata() { $viewer = $this->getViewer(); $inline = $this->getInlineComment(); $is_synthetic = (bool)$inline->getSyntheticAuthor(); $is_fixed = false; switch ($inline->getFixedState()) { case PhabricatorInlineComment::STATE_DONE: case PhabricatorInlineComment::STATE_DRAFT: $is_fixed = true; break; } $is_draft_done = false; switch ($inline->getFixedState()) { case PhabricatorInlineComment::STATE_DRAFT: case PhabricatorInlineComment::STATE_UNDRAFT: $is_draft_done = true; break; } return array( 'id' => $inline->getID(), 'phid' => $inline->getPHID(), 'changesetID' => $inline->getChangesetID(), 'number' => $inline->getLineNumber(), 'length' => $inline->getLineLength(), 'isNewFile' => (bool)$inline->getIsNewFile(), 'original' => $inline->getContent(), 'replyToCommentPHID' => $inline->getReplyToCommentPHID(), 'isDraft' => $inline->isDraft(), 'isFixed' => $is_fixed, 'isGhost' => $inline->getIsGhost(), 'isSynthetic' => $is_synthetic, 'isDraftDone' => $is_draft_done, 'isEditing' => $inline->getIsEditing(), + 'documentEngineKey' => $inline->getDocumentEngineKey(), + 'startOffset' => $inline->getStartOffset(), + 'endOffset' => $inline->getEndOffset(), 'on_right' => $this->getIsOnRight(), ); } } diff --git a/src/infrastructure/internationalization/translation/PhabricatorUSEnglishTranslation.php b/src/infrastructure/internationalization/translation/PhabricatorUSEnglishTranslation.php index 7447dd34d5..c8a12b6bfb 100644 --- a/src/infrastructure/internationalization/translation/PhabricatorUSEnglishTranslation.php +++ b/src/infrastructure/internationalization/translation/PhabricatorUSEnglishTranslation.php @@ -1,1740 +1,1765 @@ array( 'This configuration value is related:', 'These configuration values are related:', ), '%s Task(s)' => array('Task', 'Tasks'), '%s ERROR(S)' => array('ERROR', 'ERRORS'), '%d Error(s)' => array('%d Error', '%d Errors'), '%d Warning(s)' => array('%d Warning', '%d Warnings'), '%d Auto-Fix(es)' => array('%d Auto-Fix', '%d Auto-Fixes'), '%d Advice(s)' => array('%d Advice', '%d Pieces of Advice'), '%d Detail(s)' => array('%d Detail', '%d Details'), '(%d line(s))' => array('(%d line)', '(%d lines)'), '%d line(s)' => array('%d line', '%d lines'), '%d path(s)' => array('%d path', '%d paths'), '%d diff(s)' => array('%d diff', '%d diffs'), '%s Answer(s)' => array('%s Answer', '%s Answers'), 'Show %d Comment(s)' => array('Show %d Comment', 'Show %d Comments'), '%s DIFF LINK(S)' => array('DIFF LINK', 'DIFF LINKS'), 'You successfully created %d diff(s).' => array( 'You successfully created %d diff.', 'You successfully created %d diffs.', ), 'Diff creation failed; see body for %s error(s).' => array( 'Diff creation failed; see body for error.', 'Diff creation failed; see body for errors.', ), 'There are %d raw fact(s) in storage.' => array( 'There is %d raw fact in storage.', 'There are %d raw facts in storage.', ), 'There are %d aggregate fact(s) in storage.' => array( 'There is %d aggregate fact in storage.', 'There are %d aggregate facts in storage.', ), '%s Commit(s) Awaiting Audit' => array( '%s Commit Awaiting Audit', '%s Commits Awaiting Audit', ), '%s Problem Commit(s)' => array( '%s Problem Commit', '%s Problem Commits', ), '%s Review(s) Blocking Others' => array( '%s Review Blocking Others', '%s Reviews Blocking Others', ), '%s Review(s) Need Attention' => array( '%s Review Needs Attention', '%s Reviews Need Attention', ), '%s Review(s) Waiting on Others' => array( '%s Review Waiting on Others', '%s Reviews Waiting on Others', ), '%s Active Review(s)' => array( '%s Active Review', '%s Active Reviews', ), '%s Flagged Object(s)' => array( '%s Flagged Object', '%s Flagged Objects', ), '%s Object(s) Tracked' => array( '%s Object Tracked', '%s Objects Tracked', ), '%s Assigned Task(s)' => array( '%s Assigned Task', '%s Assigned Tasks', ), 'Show %d Lint Message(s)' => array( 'Show %d Lint Message', 'Show %d Lint Messages', ), 'Hide %d Lint Message(s)' => array( 'Hide %d Lint Message', 'Hide %d Lint Messages', ), 'This is a binary file. It is %s byte(s) in length.' => array( 'This is a binary file. It is %s byte in length.', 'This is a binary file. It is %s bytes in length.', ), '%s Action(s) Have No Effect' => array( 'Action Has No Effect', 'Actions Have No Effect', ), '%s Action(s) With No Effect' => array( 'Action With No Effect', 'Actions With No Effect', ), 'Some of your %s action(s) have no effect:' => array( 'One of your actions has no effect:', 'Some of your actions have no effect:', ), 'Apply remaining %d action(s)?' => array( 'Apply remaining action?', 'Apply remaining actions?', ), 'Apply %d Other Action(s)' => array( 'Apply Remaining Action', 'Apply Remaining Actions', ), 'The %s action(s) you are taking have no effect:' => array( 'The action you are taking has no effect:', 'The actions you are taking have no effect:', ), '%s edited member(s), added %d: %s; removed %d: %s.' => '%s edited members, added: %3$s; removed: %5$s.', '%s added %s member(s): %s.' => array( array( '%s added a member: %3$s.', '%s added members: %3$s.', ), ), '%s removed %s member(s): %s.' => array( array( '%s removed a member: %3$s.', '%s removed members: %3$s.', ), ), '%s edited project(s), added %s: %s; removed %s: %s.' => '%s edited projects, added: %3$s; removed: %5$s.', '%s added %s project(s): %s.' => array( array( '%s added a project: %3$s.', '%s added projects: %3$s.', ), ), '%s removed %s project(s): %s.' => array( array( '%s removed a project: %3$s.', '%s removed projects: %3$s.', ), ), '%s merged %s task(s): %s.' => array( array( '%s merged a task: %3$s.', '%s merged tasks: %3$s.', ), ), '%s merged %s task(s) %s into %s.' => array( array( '%s merged %3$s into %4$s.', '%s merged tasks %3$s into %4$s.', ), ), '%s added %s voting user(s): %s.' => array( array( '%s added a voting user: %3$s.', '%s added voting users: %3$s.', ), ), '%s removed %s voting user(s): %s.' => array( array( '%s removed a voting user: %3$s.', '%s removed voting users: %3$s.', ), ), '%s added %s subtask(s): %s.' => array( array( '%s added a subtask: %3$s.', '%s added subtasks: %3$s.', ), ), '%s added %s parent task(s): %s.' => array( array( '%s added a parent task: %3$s.', '%s added parent tasks: %3$s.', ), ), '%s removed %s subtask(s): %s.' => array( array( '%s removed a subtask: %3$s.', '%s removed subtasks: %3$s.', ), ), '%s removed %s parent task(s): %s.' => array( array( '%s removed a parent task: %3$s.', '%s removed parent tasks: %3$s.', ), ), '%s added %s subtask(s) for %s: %s.' => array( array( '%s added a subtask for %3$s: %4$s.', '%s added subtasks for %3$s: %4$s.', ), ), '%s added %s parent task(s) for %s: %s.' => array( array( '%s added a parent task for %3$s: %4$s.', '%s added parent tasks for %3$s: %4$s.', ), ), '%s removed %s subtask(s) for %s: %s.' => array( array( '%s removed a subtask for %3$s: %4$s.', '%s removed subtasks for %3$s: %4$s.', ), ), '%s removed %s parent task(s) for %s: %s.' => array( array( '%s removed a parent task for %3$s: %4$s.', '%s removed parent tasks for %3$s: %4$s.', ), ), '%s edited subtask(s), added %s: %s; removed %s: %s.' => '%s edited subtasks, added: %3$s; removed: %5$s.', '%s edited subtask(s) for %s, added %s: %s; removed %s: %s.' => '%s edited subtasks for %s, added: %4$s; removed: %6$s.', '%s edited parent task(s), added %s: %s; removed %s: %s.' => '%s edited parent tasks, added: %3$s; removed: %5$s.', '%s edited parent task(s) for %s, added %s: %s; removed %s: %s.' => '%s edited parent tasks for %s, added: %4$s; removed: %6$s.', '%s edited answer(s), added %s: %s; removed %d: %s.' => '%s edited answers, added: %3$s; removed: %5$s.', '%s added %s answer(s): %s.' => array( array( '%s added an answer: %3$s.', '%s added answers: %3$s.', ), ), '%s removed %s answer(s): %s.' => array( array( '%s removed a answer: %3$s.', '%s removed answers: %3$s.', ), ), '%s edited question(s), added %s: %s; removed %s: %s.' => '%s edited questions, added: %3$s; removed: %5$s.', '%s added %s question(s): %s.' => array( array( '%s added a question: %3$s.', '%s added questions: %3$s.', ), ), '%s removed %s question(s): %s.' => array( array( '%s removed a question: %3$s.', '%s removed questions: %3$s.', ), ), '%s edited mock(s), added %s: %s; removed %s: %s.' => '%s edited mocks, added: %3$s; removed: %5$s.', '%s added %s mock(s): %s.' => array( array( '%s added a mock: %3$s.', '%s added mocks: %3$s.', ), ), '%s removed %s mock(s): %s.' => array( array( '%s removed a mock: %3$s.', '%s removed mocks: %3$s.', ), ), '%s added %s task(s): %s.' => array( array( '%s added a task: %3$s.', '%s added tasks: %3$s.', ), ), '%s removed %s task(s): %s.' => array( array( '%s removed a task: %3$s.', '%s removed tasks: %3$s.', ), ), '%s edited file(s), added %s: %s; removed %s: %s.' => '%s edited files, added: %3$s; removed: %5$s.', '%s added %s file(s): %s.' => array( array( '%s added a file: %3$s.', '%s added files: %3$s.', ), ), '%s removed %s file(s): %s.' => array( array( '%s removed a file: %3$s.', '%s removed files: %3$s.', ), ), '%s edited contributor(s), added %s: %s; removed %s: %s.' => '%s edited contributors, added: %3$s; removed: %5$s.', '%s added %s contributor(s): %s.' => array( array( '%s added a contributor: %3$s.', '%s added contributors: %3$s.', ), ), '%s removed %s contributor(s): %s.' => array( array( '%s removed a contributor: %3$s.', '%s removed contributors: %3$s.', ), ), '%s edited %s reviewer(s), added %s: %s; removed %s: %s.' => '%s edited reviewers, added: %4$s; removed: %6$s.', '%s edited %s reviewer(s) for %s, added %s: %s; removed %s: %s.' => '%s edited reviewers for %3$s, added: %5$s; removed: %7$s.', '%s added %s reviewer(s): %s.' => array( array( '%s added a reviewer: %3$s.', '%s added reviewers: %3$s.', ), ), '%s added %s reviewer(s) for %s: %s.' => array( array( '%s added a reviewer for %3$s: %4$s.', '%s added reviewers for %3$s: %4$s.', ), ), '%s removed %s reviewer(s): %s.' => array( array( '%s removed a reviewer: %3$s.', '%s removed reviewers: %3$s.', ), ), '%s removed %s reviewer(s) for %s: %s.' => array( array( '%s removed a reviewer for %3$s: %4$s.', '%s removed reviewers for %3$s: %4$s.', ), ), '%d other(s)' => array( '1 other', '%d others', ), '%s edited subscriber(s), added %d: %s; removed %d: %s.' => '%s edited subscribers, added: %3$s; removed: %5$s.', '%s added %d subscriber(s): %s.' => array( array( '%s added a subscriber: %3$s.', '%s added subscribers: %3$s.', ), ), '%s removed %d subscriber(s): %s.' => array( array( '%s removed a subscriber: %3$s.', '%s removed subscribers: %3$s.', ), ), '%s edited watcher(s), added %s: %s; removed %d: %s.' => '%s edited watchers, added: %3$s; removed: %5$s.', '%s added %s watcher(s): %s.' => array( array( '%s added a watcher: %3$s.', '%s added watchers: %3$s.', ), ), '%s removed %s watcher(s): %s.' => array( array( '%s removed a watcher: %3$s.', '%s removed watchers: %3$s.', ), ), '%s edited participant(s), added %d: %s; removed %d: %s.' => '%s edited participants, added: %3$s; removed: %5$s.', '%s added %d participant(s): %s.' => array( array( '%s added a participant: %3$s.', '%s added participants: %3$s.', ), ), '%s removed %d participant(s): %s.' => array( array( '%s removed a participant: %3$s.', '%s removed participants: %3$s.', ), ), '%s edited image(s), added %d: %s; removed %d: %s.' => '%s edited images, added: %3$s; removed: %5$s', '%s added %d image(s): %s.' => array( array( '%s added an image: %3$s.', '%s added images: %3$s.', ), ), '%s removed %d image(s): %s.' => array( array( '%s removed an image: %3$s.', '%s removed images: %3$s.', ), ), '%s Line(s)' => array( '%s Line', '%s Lines', ), 'Indexing %d object(s) of type %s.' => array( 'Indexing %d object of type %s.', 'Indexing %d object of type %s.', ), 'Run these %d command(s):' => array( 'Run this command:', 'Run these commands:', ), 'Install these %d PHP extension(s):' => array( 'Install this PHP extension:', 'Install these PHP extensions:', ), 'The current Phabricator configuration has these %d value(s):' => array( 'The current Phabricator configuration has this value:', 'The current Phabricator configuration has these values:', ), 'The current MySQL configuration has these %d value(s):' => array( 'The current MySQL configuration has this value:', 'The current MySQL configuration has these values:', ), 'You can update these %d value(s) here:' => array( 'You can update this value here:', 'You can update these values here:', ), 'The current PHP configuration has these %d value(s):' => array( 'The current PHP configuration has this value:', 'The current PHP configuration has these values:', ), 'To update these %d value(s), edit your PHP configuration file.' => array( 'To update this %d value, edit your PHP configuration file.', 'To update these %d values, edit your PHP configuration file.', ), 'To update these %d value(s), edit your PHP configuration file, located '. 'here:' => array( 'To update this value, edit your PHP configuration file, located '. 'here:', 'To update these values, edit your PHP configuration file, located '. 'here:', ), 'PHP also loaded these %s configuration file(s):' => array( 'PHP also loaded this configuration file:', 'PHP also loaded these configuration files:', ), '%s added %d inline comment(s).' => array( array( '%s added an inline comment.', '%s added inline comments.', ), ), '%s comment(s)' => array('%s comment', '%s comments'), '%s rejection(s)' => array('%s rejection', '%s rejections'), '%s update(s)' => array('%s update', '%s updates'), 'This configuration value is defined in these %d '. 'configuration source(s): %s.' => array( 'This configuration value is defined in this '. 'configuration source: %2$s.', 'This configuration value is defined in these %d '. 'configuration sources: %s.', ), '%s Open Pull Request(s)' => array( '%s Open Pull Request', '%s Open Pull Requests', ), 'Stale (%s day(s))' => array( 'Stale (%s day)', 'Stale (%s days)', ), 'Old (%s day(s))' => array( 'Old (%s day)', 'Old (%s days)', ), '%s Commit(s)' => array( '%s Commit', '%s Commits', ), '%s attached %d file(s): %s.' => array( array( '%s attached a file: %3$s.', '%s attached files: %3$s.', ), ), '%s detached %d file(s): %s.' => array( array( '%s detached a file: %3$s.', '%s detached files: %3$s.', ), ), '%s changed file(s), attached %d: %s; detached %d: %s.' => '%s changed files, attached: %3$s; detached: %5$s.', '%s added %s parent revision(s): %s.' => array( array( '%s added a parent revision: %3$s.', '%s added parent revisions: %3$s.', ), ), '%s added %s parent revision(s) for %s: %s.' => array( array( '%s added a parent revision for %3$s: %4$s.', '%s added parent revisions for %3$s: %4$s.', ), ), '%s removed %s parent revision(s): %s.' => array( array( '%s removed a parent revision: %3$s.', '%s removed parent revisions: %3$s.', ), ), '%s removed %s parent revision(s) for %s: %s.' => array( array( '%s removed a parent revision for %3$s: %4$s.', '%s removed parent revisions for %3$s: %4$s.', ), ), '%s edited parent revision(s), added %s: %s; removed %s: %s.' => array( '%s edited parent revisions, added: %3$s; removed: %5$s.', ), '%s edited parent revision(s) for %s, '. 'added %s: %s; removed %s: %s.' => array( '%s edited parent revisions for %s, added: %3$s; removed: %5$s.', ), '%s added %s child revision(s): %s.' => array( array( '%s added a child revision: %3$s.', '%s added child revisions: %3$s.', ), ), '%s added %s child revision(s) for %s: %s.' => array( array( '%s added a child revision for %3$s: %4$s.', '%s added child revisions for %3$s: %4$s.', ), ), '%s removed %s child revision(s): %s.' => array( array( '%s removed a child revision: %3$s.', '%s removed child revisions: %3$s.', ), ), '%s removed %s child revision(s) for %s: %s.' => array( array( '%s removed a child revision for %3$s: %4$s.', '%s removed child revisions for %3$s: %4$s.', ), ), '%s edited child revision(s), added %s: %s; removed %s: %s.' => array( '%s edited child revisions, added: %3$s; removed: %5$s.', ), '%s edited child revision(s) for %s, '. 'added %s: %s; removed %s: %s.' => array( '%s edited child revisions for %s, added: %3$s; removed: %5$s.', ), '%s added %s commit(s): %s.' => array( array( '%s added a commit: %3$s.', '%s added commits: %3$s.', ), ), '%s removed %s commit(s): %s.' => array( array( '%s removed a commit: %3$s.', '%s removed commits: %3$s.', ), ), '%s edited commit(s), added %s: %s; removed %s: %s.' => '%s edited commits, added %3$s; removed %5$s.', '%s added %s reverted change(s): %s.' => array( array( '%s added a reverted change: %3$s.', '%s added reverted changes: %3$s.', ), ), '%s removed %s reverted change(s): %s.' => array( array( '%s removed a reverted change: %3$s.', '%s removed reverted changes: %3$s.', ), ), '%s edited reverted change(s), added %s: %s; removed %s: %s.' => '%s edited reverted changes, added %3$s; removed %5$s.', '%s added %s reverted change(s) for %s: %s.' => array( array( '%s added a reverted change for %3$s: %4$s.', '%s added reverted changes for %3$s: %4$s.', ), ), '%s removed %s reverted change(s) for %s: %s.' => array( array( '%s removed a reverted change for %3$s: %4$s.', '%s removed reverted changes for %3$s: %4$s.', ), ), '%s edited reverted change(s) for %s, added %s: %s; removed %s: %s.' => '%s edited reverted changes for %2$s, added %4$s; removed %6$s.', '%s added %s reverting change(s): %s.' => array( array( '%s added a reverting change: %3$s.', '%s added reverting changes: %3$s.', ), ), '%s removed %s reverting change(s): %s.' => array( array( '%s removed a reverting change: %3$s.', '%s removed reverting changes: %3$s.', ), ), '%s edited reverting change(s), added %s: %s; removed %s: %s.' => '%s edited reverting changes, added %3$s; removed %5$s.', '%s added %s reverting change(s) for %s: %s.' => array( array( '%s added a reverting change for %3$s: %4$s.', '%s added reverting changes for %3$s: %4$s.', ), ), '%s removed %s reverting change(s) for %s: %s.' => array( array( '%s removed a reverting change for %3$s: %4$s.', '%s removed reverting changes for %3$s: %4$s.', ), ), '%s edited reverting change(s) for %s, added %s: %s; removed %s: %s.' => '%s edited reverting changes for %s, added %4$s; removed %6$s.', '%s changed project member(s), added %d: %s; removed %d: %s.' => '%s changed project members, added %3$s; removed %5$s.', '%s added %d project member(s): %s.' => array( array( '%s added a member: %3$s.', '%s added members: %3$s.', ), ), '%s removed %d project member(s): %s.' => array( array( '%s removed a member: %3$s.', '%s removed members: %3$s.', ), ), '%s project hashtag(s) are already used by other projects: %s.' => array( 'Project hashtag "%2$s" is already used by another project.', 'Some project hashtags are already used by other projects: %2$s.', ), '%s changed project hashtag(s), added %d: %s; removed %d: %s.' => '%s changed project hashtags, added %3$s; removed %5$s.', 'Hashtags must contain at least one letter or number. %s '. 'project hashtag(s) are invalid: %s.' => array( 'Hashtags must contain at least one letter or number. The '. 'hashtag "%2$s" is not valid.', 'Hashtags must contain at least one letter or number. These '. 'hashtags are invalid: %2$s.', ), '%s added %d project hashtag(s): %s.' => array( array( '%s added a hashtag: %3$s.', '%s added hashtags: %3$s.', ), ), '%s removed %d project hashtag(s): %s.' => array( array( '%s removed a hashtag: %3$s.', '%s removed hashtags: %3$s.', ), ), '%s changed %s hashtag(s), added %d: %s; removed %d: %s.' => '%s changed hashtags for %s, added %4$s; removed %6$s.', '%s added %d %s hashtag(s): %s.' => array( array( '%s added a hashtag to %3$s: %4$s.', '%s added hashtags to %3$s: %4$s.', ), ), '%s removed %d %s hashtag(s): %s.' => array( array( '%s removed a hashtag from %3$s: %4$s.', '%s removed hashtags from %3$s: %4$s.', ), ), '%d User(s) Need Approval' => array( '%d User Needs Approval', '%d Users Need Approval', ), '%s, %s line(s)' => array( array( '%s, %s line', '%s, %s lines', ), ), '%s pushed %d commit(s) to %s.' => array( array( '%s pushed a commit to %3$s.', '%s pushed %d commits to %s.', ), ), '%s commit(s)' => array( '1 commit', '%s commits', ), '%s removed %s JIRA issue(s): %s.' => array( array( '%s removed a JIRA issue: %3$s.', '%s removed JIRA issues: %3$s.', ), ), '%s added %s JIRA issue(s): %s.' => array( array( '%s added a JIRA issue: %3$s.', '%s added JIRA issues: %3$s.', ), ), '%s added %s required legal document(s): %s.' => array( array( '%s added a required legal document: %3$s.', '%s added required legal documents: %3$s.', ), ), '%s updated JIRA issue(s): added %s %s; removed %d %s.' => '%s updated JIRA issues: added %3$s; removed %5$s.', '%s edited %s task(s), added %s: %s; removed %s: %s.' => '%s edited tasks, added %4$s; removed %6$s.', '%s added %s task(s) to %s: %s.' => array( array( '%s added a task to %3$s: %4$s.', '%s added tasks to %3$s: %4$s.', ), ), '%s removed %s task(s) from %s: %s.' => array( array( '%s removed a task from %3$s: %4$s.', '%s removed tasks from %3$s: %4$s.', ), ), '%s edited %s task(s) for %s, added %s: %s; removed %s: %s.' => '%s edited tasks for %3$s, added: %5$s; removed %7$s.', '%s edited %s commit(s), added %s: %s; removed %s: %s.' => '%s edited commits, added %4$s; removed %6$s.', '%s added %s commit(s) to %s: %s.' => array( array( '%s added a commit to %3$s: %4$s.', '%s added commits to %3$s: %4$s.', ), ), '%s removed %s commit(s) from %s: %s.' => array( array( '%s removed a commit from %3$s: %4$s.', '%s removed commits from %3$s: %4$s.', ), ), '%s edited %s commit(s) for %s, added %s: %s; removed %s: %s.' => '%s edited commits for %3$s, added: %5$s; removed %7$s.', '%s added %s revision(s): %s.' => array( array( '%s added a revision: %3$s.', '%s added revisions: %3$s.', ), ), '%s removed %s revision(s): %s.' => array( array( '%s removed a revision: %3$s.', '%s removed revisions: %3$s.', ), ), '%s edited %s revision(s), added %s: %s; removed %s: %s.' => '%s edited revisions, added %4$s; removed %6$s.', '%s added %s revision(s) to %s: %s.' => array( array( '%s added a revision to %3$s: %4$s.', '%s added revisions to %3$s: %4$s.', ), ), '%s removed %s revision(s) from %s: %s.' => array( array( '%s removed a revision from %3$s: %4$s.', '%s removed revisions from %3$s: %4$s.', ), ), '%s edited %s revision(s) for %s, added %s: %s; removed %s: %s.' => '%s edited revisions for %3$s, added: %5$s; removed %7$s.', '%s edited %s project(s), added %s: %s; removed %s: %s.' => '%s edited projects, added %4$s; removed %6$s.', '%s added %s project(s) to %s: %s.' => array( array( '%s added a project to %3$s: %4$s.', '%s added projects to %3$s: %4$s.', ), ), '%s removed %s project(s) from %s: %s.' => array( array( '%s removed a project from %3$s: %4$s.', '%s removed projects from %3$s: %4$s.', ), ), '%s edited %s project(s) for %s, added %s: %s; removed %s: %s.' => '%s edited projects for %3$s, added: %5$s; removed %7$s.', '%s added %s panel(s): %s.' => array( array( '%s added a panel: %3$s.', '%s added panels: %3$s.', ), ), '%s removed %s panel(s): %s.' => array( array( '%s removed a panel: %3$s.', '%s removed panels: %3$s.', ), ), '%s edited %s panel(s), added %s: %s; removed %s: %s.' => '%s edited panels, added %4$s; removed %6$s.', '%s added %s dashboard(s): %s.' => array( array( '%s added a dashboard: %3$s.', '%s added dashboards: %3$s.', ), ), '%s removed %s dashboard(s): %s.' => array( array( '%s removed a dashboard: %3$s.', '%s removed dashboards: %3$s.', ), ), '%s edited %s dashboard(s), added %s: %s; removed %s: %s.' => '%s edited dashboards, added %4$s; removed %6$s.', '%s added %s edge(s): %s.' => array( array( '%s added an edge: %3$s.', '%s added edges: %3$s.', ), ), '%s added %s edge(s) to %s: %s.' => array( array( '%s added an edge to %3$s: %4$s.', '%s added edges to %3$s: %4$s.', ), ), '%s removed %s edge(s): %s.' => array( array( '%s removed an edge: %3$s.', '%s removed edges: %3$s.', ), ), '%s removed %s edge(s) from %s: %s.' => array( array( '%s removed an edge from %3$s: %4$s.', '%s removed edges from %3$s: %4$s.', ), ), '%s edited edge(s), added %s: %s; removed %s: %s.' => '%s edited edges, added: %3$s; removed: %5$s.', '%s edited %s edge(s) for %s, added %s: %s; removed %s: %s.' => '%s edited edges for %3$s, added: %5$s; removed %7$s.', '%s added %s member(s) for %s: %s.' => array( array( '%s added a member for %3$s: %4$s.', '%s added members for %3$s: %4$s.', ), ), '%s removed %s member(s) for %s: %s.' => array( array( '%s removed a member for %3$s: %4$s.', '%s removed members for %3$s: %4$s.', ), ), '%s edited %s member(s) for %s, added %s: %s; removed %s: %s.' => '%s edited members for %3$s, added: %5$s; removed %7$s.', '%d related link(s):' => array( 'Related link:', 'Related links:', ), 'You have %d unpaid invoice(s).' => array( 'You have an unpaid invoice.', 'You have unpaid invoices.', ), 'The configurations differ in the following %s way(s):' => array( 'The configurations differ:', 'The configurations differ in these ways:', ), 'Phabricator is configured with an email domain whitelist (in %s), so '. 'only users with a verified email address at one of these %s '. 'allowed domain(s) will be able to register an account: %s' => array( array( 'Phabricator is configured with an email domain whitelist (in %s), '. 'so only users with a verified email address at %3$s will be '. 'allowed to register an account.', 'Phabricator is configured with an email domain whitelist (in %s), '. 'so only users with a verified email address at one of these '. 'allowed domains will be able to register an account: %3$s', ), ), - 'Show First %d Line(s)' => array( + 'Show First %s Line(s)' => array( 'Show First Line', - 'Show First %d Lines', + 'Show First %s Lines', ), - "\xE2\x96\xB2 Show %d Line(s)" => array( + 'Show First %s Block(s)' => array( + 'Show First Block', + 'Show First %s Blocks', + ), + + "\xE2\x96\xB2 Show %s Line(s)" => array( "\xE2\x96\xB2 Show Line", - "\xE2\x96\xB2 Show %d Lines", + "\xE2\x96\xB2 Show %s Lines", ), - 'Show All %d Line(s)' => array( + "\xE2\x96\xB2 Show %s Block(s)" => array( + "\xE2\x96\xB2 Show Block", + "\xE2\x96\xB2 Show %s Blocks", + ), + + 'Show All %s Line(s)' => array( 'Show Line', - 'Show All %d Lines', + 'Show All %s Lines', + ), + + 'Show All %s Block(s)' => array( + 'Show Block', + 'Show All %s Blocks', ), - "\xE2\x96\xBC Show %d Line(s)" => array( + "\xE2\x96\xBC Show %s Line(s)" => array( "\xE2\x96\xBC Show Line", - "\xE2\x96\xBC Show %d Lines", + "\xE2\x96\xBC Show %s Lines", + ), + + "\xE2\x96\xBC Show %s Block(s)" => array( + "\xE2\x96\xBC Show Block", + "\xE2\x96\xBC Show %s Blocks", ), - 'Show Last %d Line(s)' => array( + 'Show Last %s Line(s)' => array( 'Show Last Line', - 'Show Last %d Lines', + 'Show Last %s Lines', + ), + + 'Show Last %s Block(s)' => array( + 'Show Last Block', + 'Show Last %s Blocks', ), '%s marked %s inline comment(s) as done and %s inline comment(s) as '. 'not done.' => array( array( array( '%s marked an inline comment as done and an inline comment '. 'as not done.', '%s marked an inline comment as done and %3$s inline comments '. 'as not done.', ), array( '%s marked %s inline comments as done and an inline comment '. 'as not done.', '%s marked %s inline comments as done and %s inline comments '. 'as done.', ), ), ), '%s marked %s inline comment(s) as done.' => array( array( '%s marked an inline comment as done.', '%s marked %s inline comments as done.', ), ), '%s marked %s inline comment(s) as not done.' => array( array( '%s marked an inline comment as not done.', '%s marked %s inline comments as not done.', ), ), 'These %s object(s) will be destroyed forever:' => array( 'This object will be destroyed forever:', 'These objects will be destroyed forever:', ), 'Are you absolutely certain you want to destroy these %s '. 'object(s)?' => array( 'Are you absolutely certain you want to destroy this object?', 'Are you absolutely certain you want to destroy these objects?', ), '%s added %s owner(s): %s.' => array( array( '%s added an owner: %3$s.', '%s added owners: %3$s.', ), ), '%s removed %s owner(s): %s.' => array( array( '%s removed an owner: %3$s.', '%s removed owners: %3$s.', ), ), '%s changed %s package owner(s), added %s: %s; removed %s: %s.' => array( '%s changed package owners, added: %4$s; removed: %6$s.', ), 'Found %s book(s).' => array( 'Found %s book.', 'Found %s books.', ), 'Found %s file(s)...' => array( 'Found %s file...', 'Found %s files...', ), 'Found %s file(s) in project.' => array( 'Found %s file in project.', 'Found %s files in project.', ), 'Found %s unatomized, uncached file(s).' => array( 'Found %s unatomized, uncached file.', 'Found %s unatomized, uncached files.', ), 'Found %s file(s) to atomize.' => array( 'Found %s file to atomize.', 'Found %s files to atomize.', ), 'Atomizing %s file(s).' => array( 'Atomizing %s file.', 'Atomizing %s files.', ), 'Creating %s document(s).' => array( 'Creating %s document.', 'Creating %s documents.', ), 'Deleting %s document(s).' => array( 'Deleting %s document.', 'Deleting %s documents.', ), 'Found %s obsolete atom(s) in graph.' => array( 'Found %s obsolete atom in graph.', 'Found %s obsolete atoms in graph.', ), 'Found %s new atom(s) in graph.' => array( 'Found %s new atom in graph.', 'Found %s new atoms in graph.', ), 'This call takes %s parameter(s), but only %s are documented.' => array( array( 'This call takes %s parameter, but only %s is documented.', 'This call takes %s parameter, but only %s are documented.', ), array( 'This call takes %s parameters, but only %s is documented.', 'This call takes %s parameters, but only %s are documented.', ), ), '%s Passed Test(s)' => '%s Passed', '%s Failed Test(s)' => '%s Failed', '%s Skipped Test(s)' => '%s Skipped', '%s Broken Test(s)' => '%s Broken', '%s Unsound Test(s)' => '%s Unsound', '%s Other Test(s)' => '%s Other', '%s Bulk Task(s)' => array( '%s Task', '%s Tasks', ), '%s added %s badge(s) for %s: %s.' => array( array( '%s added a badge for %s: %3$s.', '%s added badges for %s: %3$s.', ), ), '%s added %s badge(s): %s.' => array( array( '%s added a badge: %3$s.', '%s added badges: %3$s.', ), ), '%s awarded %s recipient(s) for %s: %s.' => array( array( '%s awarded %3$s to %4$s.', '%s awarded %3$s to multiple recipients: %4$s.', ), ), '%s awarded %s recipients(s): %s.' => array( array( '%s awarded a recipient: %3$s.', '%s awarded multiple recipients: %3$s.', ), ), '%s edited badge(s) for %s, added %s: %s; revoked %s: %s.' => array( array( '%s edited badges for %s, added %s: %s; revoked %s: %s.', '%s edited badges for %s, added %s: %s; revoked %s: %s.', ), ), '%s edited badge(s), added %s: %s; revoked %s: %s.' => array( array( '%s edited badges, added %s: %s; revoked %s: %s.', '%s edited badges, added %s: %s; revoked %s: %s.', ), ), '%s edited recipient(s) for %s, awarded %s: %s; revoked %s: %s.' => array( array( '%s edited recipients for %s, awarded %s: %s; revoked %s: %s.', '%s edited recipients for %s, awarded %s: %s; revoked %s: %s.', ), ), '%s edited recipient(s), awarded %s: %s; revoked %s: %s.' => array( array( '%s edited recipients, awarded %s: %s; revoked %s: %s.', '%s edited recipients, awarded %s: %s; revoked %s: %s.', ), ), '%s revoked %s badge(s) for %s: %s.' => array( array( '%s revoked a badge for %3$s: %4$s.', '%s revoked multiple badges for %3$s: %4$s.', ), ), '%s revoked %s badge(s): %s.' => array( array( '%s revoked a badge: %3$s.', '%s revoked multiple badges: %3$s.', ), ), '%s revoked %s recipient(s) for %s: %s.' => array( array( '%s revoked %3$s from %4$s.', '%s revoked multiple recipients for %3$s: %4$s.', ), ), '%s revoked %s recipients(s): %s.' => array( array( '%s revoked a recipient: %3$s.', '%s revoked multiple recipients: %3$s.', ), ), '%s automatically subscribed target(s) were not affected: %s.' => array( 'An automatically subscribed target was not affected: %2$s.', 'Automatically subscribed targets were not affected: %2$s.', ), 'Declined to resubscribe %s target(s) because they previously '. 'unsubscribed: %s.' => array( 'Delined to resubscribe a target because they previously '. 'unsubscribed: %2$s.', 'Declined to resubscribe targets because they previously '. 'unsubscribed: %2$s.', ), '%s target(s) are not subscribed: %s.' => array( 'A target is not subscribed: %2$s.', 'Targets are not subscribed: %2$s.', ), '%s target(s) are already subscribed: %s.' => array( 'A target is already subscribed: %2$s.', 'Targets are already subscribed: %2$s.', ), 'Added %s subscriber(s): %s.' => array( 'Added a subscriber: %2$s.', 'Added subscribers: %2$s.', ), 'Removed %s subscriber(s): %s.' => array( 'Removed a subscriber: %2$s.', 'Removed subscribers: %2$s.', ), 'Queued email to be delivered to %s target(s): %s.' => array( 'Queued email to be delivered to target: %2$s.', 'Queued email to be delivered to targets: %2$s.', ), 'Queued email to be delivered to %s target(s), ignoring their '. 'notification preferences: %s.' => array( 'Queued email to be delivered to target, ignoring notification '. 'preferences: %2$s.', 'Queued email to be delivered to targets, ignoring notification '. 'preferences: %2$s.', ), '%s project(s) are not associated: %s.' => array( 'A project is not associated: %2$s.', 'Projects are not associated: %2$s.', ), '%s project(s) are already associated: %s.' => array( 'A project is already associated: %2$s.', 'Projects are already associated: %2$s.', ), 'Added %s project(s): %s.' => array( 'Added a project: %2$s.', 'Added projects: %2$s.', ), 'Removed %s project(s): %s.' => array( 'Removed a project: %2$s.', 'Removed projects: %2$s.', ), 'Added %s reviewer(s): %s.' => array( 'Added a reviewer: %2$s.', 'Added reviewers: %2$s.', ), 'Added %s blocking reviewer(s): %s.' => array( 'Added a blocking reviewer: %2$s.', 'Added blocking reviewers: %2$s.', ), 'Required %s signature(s): %s.' => array( 'Required a signature: %2$s.', 'Required signatures: %2$s.', ), 'Started %s build(s): %s.' => array( 'Started a build: %2$s.', 'Started builds: %2$s.', ), 'Added %s auditor(s): %s.' => array( 'Added an auditor: %2$s.', 'Added auditors: %2$s.', ), '%s target(s) do not have permission to see this object: %s.' => array( 'A target does not have permission to see this object: %2$s.', 'Targets do not have permission to see this object: %2$s.', ), 'This action has no effect on %s target(s): %s.' => array( 'This action has no effect on a target: %2$s.', 'This action has no effect on targets: %2$s.', ), 'Mail sent in the last %s day(s).' => array( 'Mail sent in the last day.', 'Mail sent in the last %s days.', ), '%s Day(s)' => array( '%s Day', '%s Days', ), '%s Day(s) Ago' => array( '%s Day Ago', '%s Days Ago', ), 'Setting retention policy for "%s" to %s day(s).' => array( array( 'Setting retention policy for "%s" to one day.', 'Setting retention policy for "%s" to %s days.', ), ), 'Waiting %s second(s) for lease to activate.' => array( 'Waiting a second for lease to activate.', 'Waiting %s seconds for lease to activate.', ), '%s changed %s automation blueprint(s), added %s: %s; removed %s: %s.' => '%s changed automation blueprints, added: %4$s; removed: %6$s.', '%s added %s automation blueprint(s): %s.' => array( array( '%s added an automation blueprint: %3$s.', '%s added automation blueprints: %3$s.', ), ), '%s removed %s automation blueprint(s): %s.' => array( array( '%s removed an automation blueprint: %3$s.', '%s removed automation blueprints: %3$s.', ), ), 'WARNING: There are %s unapproved authorization(s)!' => array( 'WARNING: There is an unapproved authorization!', 'WARNING: There are unapproved authorizations!', ), 'Found %s Open Resource(s)' => array( 'Found %s Open Resource', 'Found %s Open Resources', ), '%s Open Resource(s) Remain' => array( '%s Open Resource Remain', '%s Open Resources Remain', ), 'Found %s Blueprint(s)' => array( 'Found %s Blueprint', 'Found %s Blueprints', ), '%s Blueprint(s) Can Allocate' => array( '%s Blueprint Can Allocate', '%s Blueprints Can Allocate', ), '%s Blueprint(s) Enabled' => array( '%s Blueprint Enabled', '%s Blueprints Enabled', ), '%s Event(s)' => array( '%s Event', '%s Events', ), '%s Unit(s)' => array( '%s Unit', '%s Units', ), 'QUEUEING TASKS (%s Commit(s)):' => array( 'QUEUEING TASKS (%s Commit):', 'QUEUEING TASKS (%s Commits):', ), 'Found %s total commit(s); updating...' => array( 'Found %s total commit; updating...', 'Found %s total commits; updating...', ), 'Not enough process slots to schedule the other %s '. 'repository(s) for updates yet.' => array( 'Not enough process slots to schedule the other '.' repository for update yet.', 'Not enough process slots to schedule the other %s '. 'repositories for updates yet.', ), '%s updated %s, added %d: %s.' => '%s updated %s, added: %4$s.', '%s updated %s, removed %s: %s.' => '%s updated %s, removed: %4$s.', '%s updated %s, added %s: %s; removed %s: %s.' => '%s updated %s, added: %4$s; removed: %6$s.', '%s updated %s for %s, added %d: %s.' => '%s updated %s for %s, added: %5$s.', '%s updated %s for %s, removed %s: %s.' => '%s updated %s for %s, removed: %5$s.', '%s updated %s for %s, added %s: %s; removed %s: %s.' => '%s updated %s for %s, added: %5$s; removed; %7$s.', 'Permanently destroyed %s object(s).' => array( 'Permanently destroyed %s object.', 'Permanently destroyed %s objects.', ), '%s added %s watcher(s) for %s: %s.' => array( array( '%s added a watcher for %3$s: %4$s.', '%s added watchers for %3$s: %4$s.', ), ), '%s removed %s watcher(s) for %s: %s.' => array( array( '%s removed a watcher for %3$s: %4$s.', '%s removed watchers for %3$s: %4$s.', ), ), '%s awarded this badge to %s recipient(s): %s.' => array( array( '%s awarded this badge to recipient: %3$s.', '%s awarded this badge to recipients: %3$s.', ), ), '%s revoked this badge from %s recipient(s): %s.' => array( array( '%s revoked this badge from recipient: %3$s.', '%s revoked this badge from recipients: %3$s.', ), ), '%s awarded %s to %s recipient(s): %s.' => array( array( array( '%s awarded %s to recipient: %4$s.', '%s awarded %s to recipients: %4$s.', ), ), ), '%s revoked %s from %s recipient(s): %s.' => array( array( array( '%s revoked %s from recipient: %4$s.', '%s revoked %s from recipients: %4$s.', ), ), ), '%s invited %s attendee(s): %s.' => '%s invited: %3$s.', '%s uninvited %s attendee(s): %s.' => '%s uninvited: %3$s.', '%s invited %s attendee(s): %s; uninvited %s attendee(s): %s.' => '%s invited: %3$s; uninvited: %5$s.', '%s invited %s attendee(s) to %s: %s.' => '%s added invites for %3$s: %4$s.', '%s uninvited %s attendee(s) to %s: %s.' => '%s removed invites for %3$s: %4$s.', '%s updated the invite list for %s, invited %s: %s; uninvited %s: %s.' => '%s updated the invite list for %s, invited: %4$s; uninvited: %6$s.', 'Restart %s build(s)?' => array( 'Restart %s build?', 'Restart %s builds?', ), '%s is starting in %s minute(s), at %s.' => array( array( '%s is starting in one minute, at %3$s.', '%s is starting in %s minutes, at %s.', ), ), '%s added %s auditor(s): %s.' => array( array( '%s added an auditor: %3$s.', '%s added auditors: %3$s.', ), ), '%s removed %s auditor(s): %s.' => array( array( '%s removed an auditor: %3$s.', '%s removed auditors: %3$s.', ), ), '%s edited %s auditor(s), removed %s: %s; added %s: %s.' => array( array( '%s edited auditors, removed: %4$s; added: %6$s.', ), ), '%s accepted this revision as %s reviewer(s): %s.' => '%s accepted this revision as: %3$s.', '%s added %s merchant manager(s): %s.' => array( array( '%s added a merchant manager: %3$s.', '%s added merchant managers: %3$s.', ), ), '%s removed %s merchant manager(s): %s.' => array( array( '%s removed a merchant manager: %3$s.', '%s removed merchant managers: %3$s.', ), ), '%s added %s account manager(s): %s.' => array( array( '%s added an account manager: %3$s.', '%s added account managers: %3$s.', ), ), '%s removed %s account manager(s): %s.' => array( array( '%s removed an account manager: %3$s.', '%s removed account managers: %3$s.', ), ), 'You are about to apply a bulk edit which will affect '. '%s object(s).' => array( 'You are about to apply a bulk edit to a single object.', 'You are about to apply a bulk edit which will affect '. '%s objects.', ), 'Destroyed %s credential(s) of type "%s".' => array( 'Destroyed one credential of type "%2$s".', 'Destroyed %s credentials of type "%s".', ), '%s notification(s) about objects which no longer exist or which '. 'you can no longer see were discarded.' => array( 'One notification about an object which no longer exists or which '. 'you can no longer see was discarded.', '%s notifications about objects which no longer exist or which '. 'you can no longer see were discarded.', ), 'This draft revision will be sent for review once %s '. 'build(s) pass: %s.' => array( 'This draft revision will be sent for review once this build '. 'passes: %2$s.', 'This draft revision will be sent for review once these builds '. 'pass: %2$s.', ), 'This factor recently issued a challenge to a different login '. 'session. Wait %s second(s) for the code to cycle, then try '. 'again.' => array( 'This factor recently issued a challenge to a different login '. 'session. Wait %s second for the code to cycle, then try '. 'again.', 'This factor recently issued a challenge to a different login '. 'session. Wait %s seconds for the code to cycle, then try '. 'again.', ), 'This factor recently issued a challenge for a different '. 'workflow. Wait %s second(s) for the code to cycle, then try '. 'again.' => array( 'This factor recently issued a challenge for a different '. 'workflow. Wait %s second for the code to cycle, then try '. 'again.', 'This factor recently issued a challenge for a different '. 'workflow. Wait %s seconds for the code to cycle, then try '. 'again.', ), 'This factor recently issued a challenge which has expired. '. 'A new challenge can not be issued yet. Wait %s second(s) for '. 'the code to cycle, then try again.' => array( 'This factor recently issued a challenge which has expired. '. 'A new challenge can not be issued yet. Wait %s second for '. 'the code to cycle, then try again.', 'This factor recently issued a challenge which has expired. '. 'A new challenge can not be issued yet. Wait %s seconds for '. 'the code to cycle, then try again.', ), 'You recently provided a response to this factor. Responses '. 'may not be reused. Wait %s second(s) for the code to cycle, '. 'then try again.' => array( 'You recently provided a response to this factor. Responses '. 'may not be reused. Wait %s second for the code to cycle, '. 'then try again.', 'You recently provided a response to this factor. Responses '. 'may not be reused. Wait %s seconds for the code to cycle, '. 'then try again.', ), 'View All %d Subscriber(s)' => array( 'View Subscriber', 'View All %d Subscribers', ), 'You are currently editing %s inline comment(s) on this '. 'revision.' => array( 'You are currently editing an inline comment on this revision.', 'You are currently editing %s inline comments on this revision.', ), 'These %s inline comment(s) will be saved and published.' => array( 'This inline comment will be saved and published.', 'These inline comments will be saved and published.', ), ); } } diff --git a/src/view/phui/PHUITimelineEventView.php b/src/view/phui/PHUITimelineEventView.php index 83c1889ed7..73acf437af 100644 --- a/src/view/phui/PHUITimelineEventView.php +++ b/src/view/phui/PHUITimelineEventView.php @@ -1,767 +1,767 @@ authorPHID = $author_phid; return $this; } public function getAuthorPHID() { return $this->authorPHID; } public function setQuoteRef($quote_ref) { $this->quoteRef = $quote_ref; return $this; } public function getQuoteRef() { return $this->quoteRef; } public function setQuoteTargetID($quote_target_id) { $this->quoteTargetID = $quote_target_id; return $this; } public function getQuoteTargetID() { return $this->quoteTargetID; } public function setIsNormalComment($is_normal_comment) { $this->isNormalComment = $is_normal_comment; return $this; } public function getIsNormalComment() { return $this->isNormalComment; } public function setHideByDefault($hide_by_default) { $this->hideByDefault = $hide_by_default; return $this; } public function getHideByDefault() { return $this->hideByDefault; } public function setTransactionPHID($transaction_phid) { $this->transactionPHID = $transaction_phid; return $this; } public function getTransactionPHID() { return $this->transactionPHID; } public function setIsEdited($is_edited) { $this->isEdited = $is_edited; return $this; } public function getIsEdited() { return $this->isEdited; } public function setIsPreview($is_preview) { $this->isPreview = $is_preview; return $this; } public function getIsPreview() { return $this->isPreview; } public function setIsEditable($is_editable) { $this->isEditable = $is_editable; return $this; } public function getIsEditable() { return $this->isEditable; } public function setCanInteract($can_interact) { $this->canInteract = $can_interact; return $this; } public function getCanInteract() { return $this->canInteract; } public function setIsRemovable($is_removable) { $this->isRemovable = $is_removable; return $this; } public function getIsRemovable() { return $this->isRemovable; } public function setDateCreated($date_created) { $this->dateCreated = $date_created; return $this; } public function getDateCreated() { return $this->dateCreated; } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } public function getContentSource() { return $this->contentSource; } public function setUserHandle(PhabricatorObjectHandle $handle) { $this->userHandle = $handle; return $this; } public function setAnchor($anchor) { $this->anchor = $anchor; return $this; } public function getAnchor() { return $this->anchor; } public function setTitle($title) { $this->title = $title; return $this; } public function addClass($class) { $this->classes[] = $class; return $this; } public function addBadge(PHUIBadgeMiniView $badge) { $this->badges[] = $badge; return $this; } public function setIcon($icon) { $this->icon = $icon; return $this; } public function setColor($color) { $this->color = $color; return $this; } public function setIsSilent($is_silent) { $this->isSilent = $is_silent; return $this; } public function getIsSilent() { return $this->isSilent; } public function setIsMFA($is_mfa) { $this->isMFA = $is_mfa; return $this; } public function getIsMFA() { return $this->isMFA; } public function setIsLockOverride($is_override) { $this->isLockOverride = $is_override; return $this; } public function getIsLockOverride() { return $this->isLockOverride; } public function setReallyMajorEvent($me) { $this->reallyMajorEvent = $me; return $this; } public function setHideCommentOptions($hide_comment_options) { $this->hideCommentOptions = $hide_comment_options; return $this; } public function getHideCommentOptions() { return $this->hideCommentOptions; } public function addPinboardItem(PHUIPinboardItemView $item) { $this->pinboardItems[] = $item; return $this; } public function setToken($token, $removed = false) { $this->token = $token; $this->tokenRemoved = $removed; return $this; } public function getEventGroup() { return array_merge(array($this), $this->eventGroup); } public function addEventToGroup(PHUITimelineEventView $event) { $this->eventGroup[] = $event; return $this; } protected function shouldRenderEventTitle() { if ($this->title === null) { return false; } return true; } protected function renderEventTitle($force_icon, $has_menu, $extra) { $title = $this->title; $title_classes = array(); $title_classes[] = 'phui-timeline-title'; $icon = null; if ($this->icon || $force_icon) { $title_classes[] = 'phui-timeline-title-with-icon'; } if ($has_menu) { $title_classes[] = 'phui-timeline-title-with-menu'; } if ($this->icon) { $fill_classes = array(); $fill_classes[] = 'phui-timeline-icon-fill'; if ($this->color) { $fill_classes[] = 'fill-has-color'; $fill_classes[] = 'phui-timeline-icon-fill-'.$this->color; } $icon = id(new PHUIIconView()) ->setIcon($this->icon) ->addClass('phui-timeline-icon'); $icon = phutil_tag( 'span', array( 'class' => implode(' ', $fill_classes), ), $icon); } $token = null; if ($this->token) { $token = id(new PHUIIconView()) ->addClass('phui-timeline-token') ->setSpriteSheet(PHUIIconView::SPRITE_TOKENS) ->setSpriteIcon($this->token); if ($this->tokenRemoved) { $token->addClass('strikethrough'); } } $title = phutil_tag( 'div', array( 'class' => implode(' ', $title_classes), ), array($icon, $token, $title, $extra)); return $title; } public function render() { $events = $this->getEventGroup(); // Move events with icons first. $icon_keys = array(); foreach ($this->getEventGroup() as $key => $event) { if ($event->icon) { $icon_keys[] = $key; } } $events = array_select_keys($events, $icon_keys) + $events; $force_icon = (bool)$icon_keys; $menu = null; $items = array(); if (!$this->getIsPreview() && !$this->getHideCommentOptions()) { foreach ($this->getEventGroup() as $event) { $items[] = $event->getMenuItems($this->anchor); } $items = array_mergev($items); } if ($items) { $icon = id(new PHUIIconView()) ->setIcon('fa-caret-down'); $aural = javelin_tag( 'span', array( 'aural' => true, ), pht('Comment Actions')); if ($items) { $sigil = 'phui-dropdown-menu'; Javelin::initBehavior('phui-dropdown-menu'); } else { $sigil = null; } $action_list = id(new PhabricatorActionListView()) ->setUser($this->getUser()); foreach ($items as $item) { $action_list->addAction($item); } $menu = javelin_tag( $items ? 'a' : 'span', array( 'href' => '#', 'class' => 'phui-timeline-menu', 'sigil' => $sigil, 'aria-haspopup' => 'true', 'aria-expanded' => 'false', 'meta' => $action_list->getDropdownMenuMetadata(), ), array( $aural, $icon, )); $has_menu = true; } else { $has_menu = false; } // Render "extra" information (timestamp, etc). $extra = $this->renderExtra($events); $show_badges = false; $group_titles = array(); $group_items = array(); $group_children = array(); foreach ($events as $event) { if ($event->shouldRenderEventTitle()) { // Render the group anchor here, outside the title box. If we render // it inside the title box it ends up completely hidden and Chrome 55 // refuses to jump to it. See T11997 for discussion. if ($extra && $this->anchor) { $group_titles[] = id(new PhabricatorAnchorView()) ->setAnchorName($this->anchor) ->render(); } $group_titles[] = $event->renderEventTitle( $force_icon, $has_menu, $extra); // Don't render this information more than once. $extra = null; } if ($event->hasChildren()) { $group_children[] = $event->renderChildren(); $show_badges = true; } } $image_uri = $this->userHandle->getImageURI(); $wedge = phutil_tag( 'div', array( 'class' => 'phui-timeline-wedge', 'style' => (nonempty($image_uri)) ? '' : 'display: none;', ), ''); $image = null; $badges = null; if ($image_uri) { $image = javelin_tag( ($this->userHandle->getURI()) ? 'a' : 'div', array( 'style' => 'background-image: url('.$image_uri.')', 'class' => 'phui-timeline-image', 'href' => $this->userHandle->getURI(), 'aural' => false, ), ''); if ($this->badges && $show_badges) { $flex = new PHUIBadgeBoxView(); $flex->addItems($this->badges); $flex->setCollapsed(true); $badges = phutil_tag( 'div', array( 'class' => 'phui-timeline-badges', ), $flex); } } $content_classes = array(); $content_classes[] = 'phui-timeline-content'; $classes = array(); $classes[] = 'phui-timeline-event-view'; if ($group_children) { $classes[] = 'phui-timeline-major-event'; $content = phutil_tag( 'div', array( 'class' => 'phui-timeline-inner-content', ), array( $group_titles, $menu, phutil_tag( 'div', array( 'class' => 'phui-timeline-core-content', ), $group_children), )); } else { $classes[] = 'phui-timeline-minor-event'; $content = $group_titles; } $content = phutil_tag( 'div', array( 'class' => 'phui-timeline-group', ), $content); // Image Events $pinboard = null; if ($this->pinboardItems) { $pinboard = new PHUIPinboardView(); foreach ($this->pinboardItems as $item) { $pinboard->addItem($item); } } $content = phutil_tag( 'div', array( 'class' => implode(' ', $content_classes), ), array($image, $badges, $wedge, $content, $pinboard)); $outer_classes = $this->classes; $outer_classes[] = 'phui-timeline-shell'; $color = null; foreach ($this->getEventGroup() as $event) { if ($event->color) { $color = $event->color; break; } } if ($color) { $outer_classes[] = 'phui-timeline-'.$color; } $sigils = array(); $meta = null; if ($this->getTransactionPHID()) { $sigils[] = 'transaction'; $meta = array( 'phid' => $this->getTransactionPHID(), 'anchor' => $this->anchor, ); } $major_event = null; if ($this->reallyMajorEvent) { $major_event = phutil_tag( 'div', array( 'class' => 'phui-timeline-event-view '. 'phui-timeline-spacer '. 'phui-timeline-spacer-bold', )); } $sigils[] = 'anchor-container'; return array( javelin_tag( 'div', array( 'class' => implode(' ', $outer_classes), 'sigil' => implode(' ', $sigils), 'meta' => $meta, ), phutil_tag( 'div', array( 'class' => implode(' ', $classes), ), $content)), $major_event, ); } private function renderExtra(array $events) { $extra = array(); if ($this->getIsPreview()) { $extra[] = pht('PREVIEW'); } else { foreach ($events as $event) { if ($event->getIsEdited()) { $extra[] = pht('Edited'); break; } } $source = $this->getContentSource(); $content_source = null; if ($source) { $content_source = id(new PhabricatorContentSourceView()) ->setContentSource($source) ->setUser($this->getUser()); $content_source = pht('Via %s', $content_source->getSourceName()); } $date_created = null; foreach ($events as $event) { if ($event->getDateCreated()) { if ($date_created === null) { $date_created = $event->getDateCreated(); } else { $date_created = min($event->getDateCreated(), $date_created); } } } if ($date_created) { $date = phabricator_datetime( $date_created, $this->getUser()); if ($this->anchor) { Javelin::initBehavior('phabricator-watch-anchor'); Javelin::initBehavior('phabricator-tooltips'); $date = array( javelin_tag( 'a', array( 'href' => '#'.$this->anchor, 'sigil' => 'has-tooltip', 'meta' => array( 'tip' => $content_source, ), ), $date), ); } $extra[] = $date; } // If this edit was applied silently, give user a hint that they should // not expect to have received any mail or notifications. if ($this->getIsSilent()) { $extra[] = id(new PHUIIconView()) ->setIcon('fa-bell-slash', 'white') ->setEmblemColor('red') ->setTooltip(pht('Silent Edit')); } // If this edit was applied while the actor was in high-security mode, // provide a hint that it was extra authentic. if ($this->getIsMFA()) { $extra[] = id(new PHUIIconView()) ->setIcon('fa-vcard', 'white') ->setEmblemColor('pink') ->setTooltip(pht('MFA Authenticated')); } if ($this->getIsLockOverride()) { $extra[] = id(new PHUIIconView()) ->setIcon('fa-chain-broken', 'white') ->setEmblemColor('violet') ->setTooltip(pht('Lock Overridden')); } } $extra = javelin_tag( 'span', array( 'class' => 'phui-timeline-extra', ), phutil_implode_html( javelin_tag( 'span', array( 'aural' => false, ), self::DELIMITER), $extra)); return $extra; } private function getMenuItems($anchor) { $xaction_phid = $this->getTransactionPHID(); $can_interact = $this->getCanInteract(); $viewer = $this->getViewer(); $is_admin = $viewer->getIsAdmin(); $items = array(); if ($this->getIsEditable()) { $items[] = id(new PhabricatorActionView()) ->setIcon('fa-pencil') ->setHref('/transactions/edit/'.$xaction_phid.'/') ->setName(pht('Edit Comment')) ->addSigil('transaction-edit') ->setDisabled(!$can_interact) ->setMetadata( array( 'anchor' => $anchor, )); } if ($this->getQuoteTargetID()) { $ref = null; if ($this->getQuoteRef()) { $ref = $this->getQuoteRef(); if ($anchor) { $ref = $ref.'#'.$anchor; } } $items[] = id(new PhabricatorActionView()) ->setIcon('fa-quote-left') ->setName(pht('Quote Comment')) ->setHref('#') ->addSigil('transaction-quote') ->setMetadata( array( 'targetID' => $this->getQuoteTargetID(), 'uri' => '/transactions/quote/'.$xaction_phid.'/', 'ref' => $ref, )); } if ($this->getIsNormalComment()) { $items[] = id(new PhabricatorActionView()) ->setIcon('fa-code') ->setHref('/transactions/raw/'.$xaction_phid.'/') - ->setName(pht('View Remarkup')) + ->setName(pht('View Raw Remarkup')) ->addSigil('transaction-raw') ->setMetadata( array( 'anchor' => $anchor, )); $content_source = $this->getContentSource(); $source_email = PhabricatorEmailContentSource::SOURCECONST; if ($content_source->getSource() == $source_email) { $source_id = $content_source->getContentSourceParameter('id'); if ($source_id) { $items[] = id(new PhabricatorActionView()) ->setIcon('fa-envelope-o') ->setHref('/transactions/raw/'.$xaction_phid.'/?email') ->setName(pht('View Email Body')) ->addSigil('transaction-raw') ->setMetadata( array( 'anchor' => $anchor, )); } } } if ($this->getIsEdited()) { $items[] = id(new PhabricatorActionView()) ->setIcon('fa-list') ->setHref('/transactions/history/'.$xaction_phid.'/') ->setName(pht('View Edit History')) ->setWorkflow(true); } if ($this->getIsRemovable()) { $items[] = id(new PhabricatorActionView()) ->setType(PhabricatorActionView::TYPE_DIVIDER); $remove_item = id(new PhabricatorActionView()) ->setIcon('fa-trash-o') ->setHref('/transactions/remove/'.$xaction_phid.'/') ->setName(pht('Remove Comment')) ->addSigil('transaction-remove') ->setMetadata( array( 'anchor' => $anchor, )); if (!$is_admin && !$can_interact) { $remove_item->setDisabled(!$is_admin && !$can_interact); } else { $remove_item->setColor(PhabricatorActionView::RED); } $items[] = $remove_item; } return $items; } } diff --git a/webroot/rsrc/css/application/differential/changeset-view.css b/webroot/rsrc/css/application/differential/changeset-view.css index 1442e7411c..5788bd68bc 100644 --- a/webroot/rsrc/css/application/differential/changeset-view.css +++ b/webroot/rsrc/css/application/differential/changeset-view.css @@ -1,489 +1,510 @@ /** * @provides differential-changeset-view-css * @requires phui-inline-comment-view-css */ .differential-changeset { position: relative; overflow-x: auto; /* Fixes what seems to be a layout bug in Firefox which causes scrollbars, to appear unpredictably, see discussion in T7690. */ overflow-y: hidden; } .device-phone .differential-changeset { overflow-x: scroll; -webkit-overflow-scrolling: touch; } .differential-diff { background: {$diff.background}; width: 100%; border-top: 1px solid {$lightblueborder}; border-bottom: 1px solid {$lightblueborder}; table-layout: fixed; } .differential-diff.diff-2up { min-width: 780px; } .differential-diff col.num { width: 45px; } .device .differential-diff.diff-1up col.num { width: 32px; } .differential-diff.diff-2up col.left, .differential-diff.diff-2up col.right { width: 49.25%; } .differential-diff.diff-1up col.unified { width: 99.5%; } .differential-diff col.copy { width: 0.5%; } .differential-diff col.cov { width: 1%; } .differential-diff td { vertical-align: top; white-space: pre-wrap; word-wrap: break-word; padding: 1px 8px; } .differential-diff td.diff-flush { padding-top: 0; padding-bottom: 0; } .device .differential-diff td { padding: 1px 4px; } .prose-diff { padding: 12px 0; white-space: pre-wrap; color: {$greytext}; } .prose-diff-frame { padding: 12px; } .prose-diff span.old, .prose-diff span.new { padding: 0 2px; } .prose-diff span.old, .prose-diff span.new { color: {$darkgreytext}; } .differential-changeset-immutable .differential-diff td { cursor: auto; } .differential-diff td.old { background: {$old-background}; } .differential-diff td.new { background: {$new-background}; } .differential-diff td.old-rebase { background: #ffeeee; } .differential-diff td.new-rebase { background: #eeffee; } .differential-diff td.old span.bright, .differential-diff td.old-full, .prose-diff span.old { background: {$old-bright}; } .differential-diff td.new span.bright, .differential-diff td.new-full, .prose-diff span.new { background: {$new-bright}; } .differential-diff td span.depth-out, .differential-diff td span.depth-in { padding: 2px 0; background-size: 12px 12px; background-repeat: no-repeat; background-position: left center; position: relative; left: -8px; opacity: 0.5; } .differential-diff td span.depth-out { background-image: url(/rsrc/image/chevron-out.png); background-color: {$old-bright}; } .differential-diff td span.depth-in { background-position: 1px center; background-image: url(/rsrc/image/chevron-in.png); background-color: {$new-bright}; } .differential-diff td.copy { min-width: 0.5%; width: 0.5%; padding: 0; background: {$lightbluebackground}; } .differential-diff td.new-copy, .differential-diff td.new-copy span.bright { background: {$copy-background}; } .differential-diff td.new-move, .differential-diff td.new-move span.bright { background: {$move-background}; } .differential-diff td.comment { background: #dddddd; } .differential-diff .inline > td { padding: 0; } /* Specify line number behaviors after other behaviors because line numbers should always have a boring grey background. */ .differential-diff td.n { text-align: right; padding: 1px 6px 1px 0; vertical-align: top; background: {$lightbluebackground}; color: {$bluetext}; cursor: pointer; border-right: 1px solid {$thinblueborder}; overflow: hidden; } .differential-diff td + td.n { border-left: 1px solid {$thinblueborder}; } .differential-diff td.n::before { content: attr(data-n); } .differential-diff td.show-context-line.n { cursor: auto; } .differential-diff td.cov { padding: 0; } td.cov-U { background: #dd8866; } td.cov-C { background: #66bbff; } td.cov-N { background: #ddeeff; } td.cov-X { background: #aa00aa; } td.cov-I { background: {$lightgreybackground}; } .differential-diff td.source-cov-C, .differential-diff td.source-cov-C span.bright { background: #cceeff; } .differential-diff td.source-cov-U, .differential-diff td.source-cov-U span.bright { background: #ffbb99; } .differential-diff td.source-cov-N, .differential-diff td.source-cov-N span.bright { background: #f3f6ff; } .differential-diff td.show-more, .differential-diff td.show-context-line, .differential-diff td.show-context, .differential-diff td.differential-shield { background: {$lightbluebackground}; padding: 12px 0; border-top: 1px solid {$thinblueborder}; border-bottom: 1px solid {$thinblueborder}; } .device .differential-diff td.show-more, .device .differential-diff td.show-context-line, .device .differential-diff td.show-context, .device .differential-diff td.differential-shield { padding: 6px 0; } .differential-diff td.show-more, .differential-diff td.differential-shield { font: {$basefont}; font-size: {$smallerfontsize}; white-space: normal; } .differential-diff td.show-more { text-align: center; color: {$bluetext}; } .differential-diff td.show-context-line { padding-right: 6px; } .differential-diff td.show-context-line.left-context { border-right: none; } .differential-diff td.show-context { padding-left: 14px; } .differential-diff td.differential-shield { text-align: center; } .differential-diff td.differential-shield a { font-weight: bold; } .differential-diff td.diff-image-cell { background-color: transparent; background-image: url(/rsrc/image/checker_light.png); padding: 8px; } .device-desktop .differential-diff .diff-image-cell:hover { background-image: url(/rsrc/image/checker_dark.png); } .differential-image-stage { overflow: auto; } .differential-meta-notice { border-top: 1px solid {$gentle.highlight.border}; border-bottom: 1px solid {$gentle.highlight.border}; background-color: {$gentle.highlight}; padding: 12px; } .differential-meta-notice + .differential-diff { border-top: none; } .differential-changeset .differential-file-icon-header { font-size: {$biggestfontsize}; padding: 18px 0 20px 12px; margin-top: 4px; line-height: 20px; color: {$blacktext}; cursor: pointer; } .differential-changeset .differential-file-icon-header .differential-changeset-path-name { cursor: auto; } .device-phone .differential-changeset .differential-file-icon-header { word-break: break-word; margin-right: 8px; } -.differential-reticle { - background-color: {$sh-yellowbackground}; - border: 1px solid {$sh-yellowborder}; - position: absolute; - opacity: 0.5; - top: 0; - left: 0; - box-sizing: border-box; - pointer-events: none; -} - .differential-loading { border-top: 1px solid {$gentle.highlight.border}; border-bottom: 1px solid {$gentle.highlight.border}; background-color: {$gentle.highlight}; padding: 12px; text-align: center; } .differential-file-icon-header .phui-icon-view { display: inline-block; margin: 0 6px 2px 0; vertical-align: middle; font-size: 14px; } .device-phone .differential-file-icon-header .phui-icon-view { display: none; } .differential-changeset-buttons { float: right; margin-top: 16px; margin-right: 12px; } .device-phone .differential-changeset-buttons .button .phui-button-text { visibility: hidden; width: 0; margin-left: 8px; } .differential-property-table { margin: 12px; background: {$lightgreybackground}; border: 1px solid {$lightblueborder}; border-bottom: 1px solid {$blueborder}; } .differential-property-table td em { color: {$lightgreytext}; } .differential-property-table td.oval { background: #ffd0d0; width: 50%; } .differential-property-table td.nval { background: #d0ffd0; width: 50%; } tr.differential-inline-hidden { display: none; } tr.differential-inline-loading { opacity: 0.5; } .differential-review-stage { position: relative; } .diff-banner { position: fixed; top: 0; left: 0; right: 0; background: {$page.content}; box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.1); border-bottom: 1px solid {$lightgreyborder}; padding: 8px 18px; vertical-align: middle; font-weight: bold; font-size: {$biggerfontsize}; line-height: 28px; } .diff-banner-path { color: {$greytext}; } .diff-banner-buttons .button { margin-left: 8px; } .diff-banner-has-unsaved, .diff-banner-has-unsubmitted, .diff-banner-has-draft-done { background: {$gentle.highlight}; } .diff-banner-buttons { float: right; } /* In Firefox, making the table unselectable and then making cells selectable does not work: the cells remain unselectable. Narrowly mark the cells as unselectable. */ .differential-diff.copy-l > tbody > tr > td, .differential-diff.copy-r > tbody > tr > td { -moz-user-select: none; -ms-user-select: none; -webkit-user-select: none; user-select: none; } .differential-diff.copy-l > tbody > tr > td:nth-child(2) { -moz-user-select: auto; -ms-user-select: auto; -webkit-user-select: auto; user-select: auto; } .differential-diff.copy-l > tbody > tr > td.show-more:nth-child(2) { -moz-user-select: none; -ms-user-select: none; -webkit-user-select: none; user-select: none; } .differential-diff.copy-r > tbody > tr > td:nth-child(5) { -moz-user-select: auto; -ms-user-select: auto; -webkit-user-select: auto; user-select: auto; } .differential-diff.copy-l > tbody > tr.inline > td, .differential-diff.copy-r > tbody > tr.inline > td { -moz-user-select: none; -ms-user-select: none; -webkit-user-select: none; user-select: none; } .changeset-content-hidden .differential-file-icon-header { background: {$lightgreybackground}; color: {$greytext}; } .changeset-selected .differential-file-icon-header { background: {$lightyellow}; color: {$blacktext}; } + +.differential-diff tr td.inline-hover { + background: {$gentle.highlight}; +} + +.differential-diff tr td.inline-hover-bright { + background: {$highlight.bright}; +} + +.differential-diff tr td.n.inline-hover { + background: {$yellow}; +} + +.inline-hover-container { + position: absolute; + color: {$lightgreytext}; + background: {$lightyellow}; +} + +.inline-hover-text { + padding-top: 2px; + padding-bottom: 2px; +} + +.inline-hover-text-bright { + color: {$blacktext}; + background: {$highlight.bright}; +} + +.differential-diff td.inline-hover span.bright { + background: transparent; +} diff --git a/webroot/rsrc/css/application/differential/phui-inline-comment.css b/webroot/rsrc/css/application/differential/phui-inline-comment.css index 2738a64749..ce5b87effd 100644 --- a/webroot/rsrc/css/application/differential/phui-inline-comment.css +++ b/webroot/rsrc/css/application/differential/phui-inline-comment.css @@ -1,429 +1,438 @@ /** * @provides phui-inline-comment-view-css */ .differential-diff td.anchor-target { background: {$lightyellow}; } /* In the document, the anchor is positioned inside the inline comment, but this makes the browser jump into the comment so the top isn't visible. Instead, artificially position it a bit above the comment so we'll jump a bit before the comment. This allows us to see the entire comment (and generally the commented-on lines, at least in the case of one or two-line comments) after the jump. */ .differential-inline-comment-anchor { position: absolute; display: block; margin-top: -72px; } .differential-inline-comment-content { overflow: auto; } .differential-inline-comment, .differential-inline-comment-edit { - background: {$page.content}; - border: 1px solid {$gentle.highlight.border}; font: {$basefont}; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; overflow: hidden; white-space: normal; border-radius: 3px; margin: 8px 12px; + background: {$page.content}; + border: 1px solid {$blueborder}; } .device .differential-inline-comment { margin: 4px; } .inline-state-is-draft { border: 1px dashed {$greyborder}; } .differential-inline-comment-head { font-weight: bold; color: {$darkbluetext}; - border-bottom: 1px solid {$gentle.highlight.border}; padding: 4px 5px 4px 8px; - background-color: {$gentle.highlight}; + + background: {$lightbluebackground}; + border-bottom: 1px solid {$blueborder}; } .differential-inline-comment-content { padding: 12px; } .inline-state-is-draft .differential-inline-comment-head { border-bottom: 1px dashed {$lightgreyborder}; background-color: {$lightgreybackground}; } /* Tighten up spacing on replies */ .differential-inline-comment.inline-comment-is-reply { margin-top: 0; } .differential-inline-comment .inline-head-right { float: right; padding-right: 4px; } .differential-inline-comment .inline-head-right .button { vertical-align: top; } .differential-inline-comment .inline-head-left { float: left; padding: 4px; } .device-phone .differential-inline-comment .inline-head-left { float: none; } .device-phone .differential-inline-comment .inline-head-right { margin: 12px 0 4px 4px; } .device-phone .differential-inline-comment .inline-head-right .mml { margin: 0 4px 0 0; } /* - Sythetic Comment --------------------------------------------------------- Comments left by our robot overlords. */ .differential-inline-comment.differential-inline-comment-synthetic { border: 1px solid {$blue}; } .differential-inline-comment.differential-inline-comment-synthetic .differential-inline-comment-head { border-bottom: 1px solid {$blueborder}; background-color: {$lightblue}; } .differential-inline-comment.differential-inline-comment-synthetic .differential-inline-comment-head { padding-bottom: 4px; } /* - Ghost Comment ------------------------------------------------------------ Comments from older or newer versions of the changeset. */ .differential-inline-comment.inline-comment-ghost { border: 1px solid {$lightgreyborder}; opacity: 0.75; } .differential-inline-comment.inline-comment-ghost .differential-inline-comment-head { border-bottom: 1px solid {$lightgreyborder}; background-color: {$lightgreybackground}; } /* - New/Edit Inline Comment -------------------------------------------------- Styles for when you are creating or editing an inline comment. */ .differential-inline-comment .done-label { display: inline-block; color: {$sh-yellowicon}; padding: 4px; } .differential-inline-comment.inline-state-is-draft .done-label, .differential-inline-comment.inline-comment-ghost .done-label { color: {$lightgreytext}; } /* - New/Edit Inline Comment -------------------------------------------------- Styles for when you are creating or editing an inline comment. */ .differential-inline-comment-edit-body .aphront-form-input { margin: 0; width: 100%; } .differential-inline-comment-edit { padding: 8px; } .differential-inline-comment-edit-buttons { padding: 8px 0 0 0; } .differential-inline-comment-edit-buttons button { float: right; margin-left: 6px; } .differential-inline-comment-edit-title { font-weight: bold; color: {$darkbluetext}; padding: 4px 0 12px; font-size: {$biggerfontsize}; } .differential-inline-comment-edit { background-color: {$lightgreybackground}; border: 1px solid {$lightgreyborder}; } .differential-inline-comment-edit .remarkup-assist-textarea { border-left-color: {$lightgreyborder}; border-right-color: {$lightgreyborder}; border-bottom-color: {$greyborder}; } .differential-inline-comment-edit .remarkup-assist-bar { border-left-color: {$lightgreyborder}; border-right-color: {$lightgreyborder}; border-top-color: {$lightgreyborder}; } .differential-inline-comment-edit .aphront-form-control-textarea { padding: 0; } /* - Action Buttons ----------------------------------------------------------- Reply, Edit, Delete, View, Button Bars... */ .differential-inline-comment .differential-inline-done-label { border-color: {$gentle.highlight.border}; color: {$bluetext}; } .differential-inline-comment.inline-state-is-draft .differential-inline-done-label, .differential-inline-comment.inline-state-is-draft .button.simple, .differential-inline-comment.inline-comment-ghost .button.simple { color: {$lightgreytext}; } /* - Done Button -------------------------------------------------------------- Default colors, hovers, checked styles for the Done Button. */ .differential-inline-done-label { border: 1px solid {$sh-yellowborder}; border-radius: 3px; display: inline-block; padding: 3px 8px 4px; cursor: pointer; } .differential-inline-done-label .differential-inline-done { margin: 0 6px 0 0; display: inline; cursor: pointer; } .differential-inline-comment.inline-is-done .differential-inline-done-label { background-color: {$page.content}; border-color: {$lightblueborder}; color: {$sky}; opacity: 1; } .device-desktop .differential-inline-comment.inline-is-done .differential-inline-done-label:hover { background-color: {$page.content}; color: {$sky}; } .differential-inline-comment.inline-is-done .differential-inline-comment-head .button-done { color: {$sky}; } - -/* - Inline Is Done ----------------------------------------------------------- - - Is Done for Diff Author = grey, for Diff Viewer = yellow. - -*/ - .differential-inline-comment.inline-is-done { - border-color: {$lightgreyborder}; + border-color: {$thingreyborder}; } .differential-inline-comment.inline-is-done .differential-inline-comment-head { background-color: {$lightgreybackground}; - border-bottom-color: {$lightgreyborder}; + border-bottom-color: {$thingreyborder}; } .differential-inline-comment.inline-is-done .differential-inline-comment-head .button.simple { border-color: {$lightgreyborder}; color: {$lightgreytext}; } .differential-inline-comment.inline-is-done .differential-inline-comment-head .differential-inline-done-label { color: {$sky}; background-color: {$page.content}; border-color: {$sky}; } /* - Inline State is Draft ---------------------------------------------------- The Unsubmitted state of the comment / done checkbox styles. */ .differential-inline-comment .inline-draft-text { display: none; } .differential-inline-comment.inline-state-is-draft .inline-draft-text { display: inline-block; } .inline-state-is-draft .differential-inline-done-label { border-style: dashed; } /* - Undo --------------------------------------------------------------------- A wild undo box appears! */ .differential-inline-undo { padding: 8px; margin: 4px 12px; text-align: center; background: {$sh-yellowbackground}; border: 1px solid {$sh-yellowborder}; color: {$darkgreytext}; font: {$basefont}; font-size: {$normalfontsize}; border-radius: 3px; } .differential-inline-undo a { font-weight: bold; } /* - Spooky Ghost UI ----------------------------------------------------------- Hide your codez. */ .inline-comment-ghost .differential-inline-comment-head { padding-left: 40px; } .ghost-icon { background: rgba({$alphagrey},.07); float: left; padding: 2px 4px 2px 2px; position: absolute; top: 0; left: 0; } .ghost-icon .phui-icon-view { padding: 8px 7px; font-size: 16px; color: {$lightbluetext}; } .device-desktop .ghost-icon .phui-icon-view:hover { color: {$fire}; } .differential-inline-comment.inline-comment-ghost .differential-inline-comment-head { position: relative; } .differential-inline-comment.inline-comment-ghost .differential-inline-done-label, .differential-inline-comment.inline-comment-ghost { border-color: {$lightgreyborder}; color: {$lightgreytext}; } /* - Hiding Inlines ------------------------------------------------------------ */ .reveal-inline { color: {$lightbluetext}; margin: 4px 0; display: none; } .inline-hidden .reveal-inline { display: block; } .inline-hidden .differential-inline-comment { display: none; } .differential-inline-summary { background: {$lightgreybackground}; padding: 2px 16px; color: {$lightgreytext}; display: none; font: {$basefont}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .device .differential-inline-summary { padding-left: 4px; padding-right: 4px; } .inline-hidden .differential-inline-summary { display: block; } .reveal-inline span.phui-icon-view { color: {$lightbluetext}; } .reveal-inline:hover span.phui-icon-view { color: {$darkbluetext}; } .inline-button-divider { border-left: 1px solid rgba({$alphagrey},.25); margin-left: 8px; } .differential-inline-comment-synthetic .inline-button-divider { border: none; } + +.inline-comment-element .differential-inline-comment-head { + cursor: pointer; +} + +.inline-comment-selected .inline-comment-element { + border-color: {$yellow}; + background: {$gentle.highlight.background}; +} + +.inline-comment-selected .inline-comment-element + .differential-inline-comment-head { + background: {$lightyellow}; + border-color: {$yellow}; +} diff --git a/webroot/rsrc/css/core/syntax.css b/webroot/rsrc/css/core/syntax.css index 0114d3fb24..78aa83dbdc 100644 --- a/webroot/rsrc/css/core/syntax.css +++ b/webroot/rsrc/css/core/syntax.css @@ -1,41 +1,41 @@ /** * @provides syntax-highlighting-css * @requires syntax-default-css */ .remarkup-code .uu { /* Forbidden Unicode */ color: #aa0066; } .remarkup-code .language-tag { color: {$lightgreytext}; } .remarkup-code td > span { display: inline; - word-break: break-all; + line-break: anywhere; } .remarkup-code .rbw_r { color: red; } .remarkup-code .rbw_o { color: orange; } .remarkup-code .rbw_y { color: yellow; } .remarkup-code .rbw_g { color: green; } .remarkup-code .rbw_b { color: blue; } .remarkup-code .rbw_i { color: indigo; } .remarkup-code .rbw_v { color: violet; } span.crossreference-item { background: {$lightyellow}; border-bottom: 1px solid {$yellow}; cursor: help; } .remarkup-code .invisible { color: #222222; background: #dddddd; } .suspicious-character { background: #ff7700; color: #ffffff; cursor: default; } diff --git a/webroot/rsrc/css/core/z-index.css b/webroot/rsrc/css/core/z-index.css index a3cd0b5702..d5ac177db4 100644 --- a/webroot/rsrc/css/core/z-index.css +++ b/webroot/rsrc/css/core/z-index.css @@ -1,187 +1,183 @@ /** * @provides phabricator-zindex-css */ .device .phabricator-action-list-view.phabricator-action-list-toggle, .device-desktop .phui-document-content .phabricator-action-list-view.phabricator-action-list-toggle { z-index: 1; } .keyboard-focus-focus-reticle { z-index: 2; } .device-desktop .phui-timeline-minor-event .phui-timeline-image { z-index: 2; } -.differential-reticle { - z-index: 2; -} - .differential-changeset { z-index: 2; } .pholio-new-inline-comment { z-index: 2; } .slowvote-bar { z-index: 2; } div.phui-calendar-day-event { z-index: 2; } .slowvote-above-the-bar { z-index: 3; } .phui-timeline-icon-fill { z-index: 3; } .phui-crumbs-view { z-index: 3; } .phabricator-nav-local { z-index: 4; } .project-board-header { z-index: 4; } .conpherence-layout .conpherence-no-threads { z-index: 4; } .conpherence-menu-pane { z-index: 4; } .conpherence-message-pane .conpherence-search-main { z-index: 4; } .dark-console { z-index: 5; } .phui-calendar-date-number { z-index: 5; } .phabricator-main-menu { z-index: 6; } .aphront-developer-error-callout { z-index: 6; } .loading .messages-loading-mask, .loading .widgets-loading-mask { z-index: 6; } .diff-banner { z-index: 6; } .conpherence-durable-column { z-index: 7; } .jx-scrollbar-bar { z-index: 8; } .differential-haunt-mode-1 .differential-add-comment-panel, .differential-haunt-mode-2 .differential-add-comment-panel { z-index: 8; } .remarkup-assist-pinned { z-index: 8; } .device-desktop .phabricator-notification-menu { z-index: 9; } .jx-mask { z-index: 10; } .jx-notification-container { z-index: 11; } .phabricator-global-upload-instructions { z-index: 11; } .lightbox-attachment { z-index: 12; } div.jx-typeahead-results { z-index: 13; } .jx-client-dialog { z-index: 14; } .fancy-datepicker { z-index: 15; } .drag-frame { z-index: 16; } .jx-hovercard-container { z-index: 17; } .pholio-device-lightbox { z-index: 20; } .phuix-autocomplete { z-index: 21; } .conpherence-participant-pane { z-index: 25; } .conpherence-layout .conpherence-loading-mask { z-index: 30; } .phuix-dropdown-menu { z-index: 32; } .busy { z-index: 40; } .remarkup-control-fullscreen-mode { z-index: 50; } .jx-tooltip-container { z-index: 51; } .audible .aural-only { z-index: 100; } diff --git a/webroot/rsrc/css/phui/phui-property-list-view.css b/webroot/rsrc/css/phui/phui-property-list-view.css index c858905449..4d2cbc9b50 100644 --- a/webroot/rsrc/css/phui/phui-property-list-view.css +++ b/webroot/rsrc/css/phui/phui-property-list-view.css @@ -1,380 +1,380 @@ /** * @provides phui-property-list-view-css */ .phui-property-list-view .keyboard-shortcuts-available { float: right; height: 16px; margin: 12px 10px -28px 0px; padding: 0px 20px 0px 0px; vertical-align: middle; color: {$greytext}; text-align: right; font-size: {$smallestfontsize}; background: url('/rsrc/image/icon/fatcow/key_question.png') right center no-repeat; } .device .keyboard-shortcuts-available { display: none; } .phui-property-group-noninitial, .phui-property-list-section-noninitial { border-color: {$thinblueborder}; border-style: solid; border-width: 1px 0 0; } .device-desktop .phui-property-list-container { padding: 12px 0 12px 0; width: 100%; } .device .phui-property-list-container { padding: 12px 0 4px 0; } .phui-property-list-key { color: {$bluetext}; font-weight: bold; overflow: hidden; white-space: nowrap; } .device-desktop .phui-property-list-key { width: 12%; margin-left: 1%; text-align: right; float: left; clear: left; margin-bottom: 4px; } .device-desktop .phui-property-list-has-actions .phui-property-list-key { width: 18%; } .phui-property-list-properties-wrap.phui-property-list-stacked { width: auto; float: none; } .device .phui-property-list-key, div.phui-property-list-stacked .phui-property-list-properties .phui-property-list-key { padding-left: 4px; text-align: left; margin-left: 0; width: auto; float: none; } .phui-property-list-value { color: {$darkgreytext}; } .device-desktop .phui-property-list-value { width: 84%; margin-left: 1%; float: left; margin-bottom: 4px; word-wrap: break-word; } .device-desktop .phui-property-list-has-actions .phui-property-list-value { width: 78%; } .device .phui-property-list-value, .phui-property-list-stacked .phui-property-list-properties .phui-property-list-value { padding: 0 8px; margin-bottom: 8px; width: auto; word-break: break-word; float: none; } .phui-property-list-section-header { color: {$bluetext}; padding: 16px 4px 0px; text-transform: uppercase; font-weight: 700; border-color: {$thinblueborder}; border-style: solid; border-width: 1px 0 0; } .phui-property-list-container + .phui-property-list-text-content { border-color: {$thinblueborder}; border-style: solid; border-width: 1px 0 0; } .phui-property-list-section-noninitial .phui-property-list-section-header { border-top: none; } .device .phui-property-list-section-header { padding-left: 4px; } .phui-property-list-section-header-icon .phui-icon-view { display: inline-block; margin: -2px 4px -2px 0; } .phui-property-list-text-content { padding: 16px 4px; overflow: hidden; } .phui-property-list-raw-content { padding: 0px; overflow: hidden; } /* In the common case where we immediately follow a header, move back up 30px so we snuggle next to the header. */ .device-desktop .phui-header-view + .phabricator-action-list-view { margin-top: -30px; } .device-desktop .phui-header-view + .phabricator-action-list-view + .phui-property-list-view { margin-top: 0px; } /* When tags appear in property lists, give them a little more vertical spacing. */ .phui-property-list-value .phui-tag-view { margin: 2px 0; white-space: pre-wrap; } .phui-property-list-has-actions .phui-property-list-properties-wrap { float: left; width: 78%; } .device .phui-property-list-properties-wrap { width: auto; border: none; float: none; overflow: auto; } .phui-property-list-actions { width: 20%; float: right; margin-right: 12px; border-left: 1px solid {$thinblueborder}; } !print .phui-property-list-actions { display: none; } .device .phui-property-list-actions { float: none; width: auto; margin: -12px 0 12px 0; border: none; } .phui-property-list-image-content img { margin: 20px auto; background: url('/rsrc/image/checker_light.png'); } .device-desktop .phui-property-list-image-content img:hover { background: url('/rsrc/image/checker_dark.png'); } /* - Dashboards ------------------------------------------------------------ */ .dashboard-panel .phui-property-list-section { border-left: 1px solid {$lightblueborder}; border-right: 1px solid {$lightblueborder}; border-bottom: 1px solid {$blueborder}; } .document-engine-image img { margin: 20px auto; background: url('/rsrc/image/checker_light.png'); max-width: 100%; } .device-desktop .document-engine-image img:hover { background: url('/rsrc/image/checker_dark.png'); } .document-engine-video video { margin: 20px auto; display: block; max-width: 95%; } .document-engine-audio audio { display: block; margin: 16px auto; width: 50%; min-width: 240px; } .document-engine-message { margin: 20px auto; text-align: center; color: {$greytext}; } .document-engine-error { margin: 20px; padding: 12px; text-align: center; color: {$redtext}; background: {$sh-redbackground}; } .document-engine-hexdump { margin: 20px; white-space: pre; } .document-engine-remarkup { margin: 20px; } .document-engine-pdf { margin: 20px; text-align: center; } .document-engine-pdf .phabricator-remarkup-embed-layout-link { text-align: left; } .document-engine-text .phabricator-source-code-container { border: none; } .document-engine-jupyter { overflow: hidden; margin: 20px; } .document-engine-jupyter.document-engine-diff { margin: 0; } .document-engine-in-flight { opacity: 0.25; } .document-engine-loading { margin: 20px; text-align: center; color: {$lightgreytext}; } .document-engine-loading .phui-icon-view { display: block; font-size: 48px; color: {$lightgreyborder}; padding: 8px; } .jupyter-cell-raw { white-space: pre-wrap; background: {$lightgreybackground}; color: {$greytext}; padding: 8px; } .jupyter-cell-code { white-space: pre-wrap; word-break: break-word; background: {$lightgreybackground}; border-radius: 2px; border-color: {$lightgreyborder}; border-style: solid; } .jupyter-cell-code-block { padding: 8px; border-width: 1px; } .jupyter-cell-code-line { padding: 2px 8px; border-width: 0 1px; } td.new .jupyter-cell-code-line { background: rgba(255, 255, 255, 0.5); border-color: {$new-bright}; } td.old .jupyter-cell-code-line { background: rgba(255, 255, 255, 0.5); border-color: {$old-bright}; } .jupyter-cell-code-head { border-top-width: 1px; margin-top: 4px; padding-top: 8px; } .jupyter-cell-code-last { border-bottom-width: 1px; margin-bottom: 4px; padding-bottom: 8px; } .jupyter-notebook > tbody > tr > td { padding: 8px; } .jupyter-notebook > tbody > tr > td.jupyter-cell-flush { padding-top: 0; padding-bottom: 0; } .jupyter-notebook, .jupyter-notebook > tbody > tr > td { width: 100%; } .jupyter-notebook > tbody > tr > td.jupyter-label { white-space: nowrap; text-align: right; min-width: 56px; font-weight: bold; width: auto; padding: 8px 8px 0; } .jupyter-output { margin: 4px 0; padding: 8px; white-space: pre-wrap; - word-break: break-all; + line-break: anywhere; } .jupyter-output-stderr { background: {$sh-redbackground}; } .jupyter-output-html { background: {$sh-indigobackground}; } .jupyter-cell-markdown { white-space: pre-wrap; } diff --git a/webroot/rsrc/js/application/diff/DiffChangeset.js b/webroot/rsrc/js/application/diff/DiffChangeset.js index a6891dabcb..ebb50eb379 100644 --- a/webroot/rsrc/js/application/diff/DiffChangeset.js +++ b/webroot/rsrc/js/application/diff/DiffChangeset.js @@ -1,1040 +1,1077 @@ /** * @provides phabricator-diff-changeset * @requires javelin-dom * javelin-util * javelin-stratcom * javelin-install * javelin-workflow * javelin-router * javelin-behavior-device * javelin-vector * phabricator-diff-inline * phabricator-diff-path-view * phuix-button-view * @javelin */ JX.install('DiffChangeset', { construct : function(node) { this._node = node; var data = this._getNodeData(); this._renderURI = data.renderURI; this._ref = data.ref; this._loaded = data.loaded; this._treeNodeID = data.treeNodeID; this._leftID = data.left; this._rightID = data.right; this._displayPath = JX.$H(data.displayPath); this._pathParts = data.pathParts; this._icon = data.icon; this._editorURI = data.editorURI; this._editorConfigureURI = data.editorConfigureURI; this._showPathURI = data.showPathURI; this._showDirectoryURI = data.showDirectoryURI; this._pathIconIcon = data.pathIconIcon; this._pathIconColor = data.pathIconColor; this._isLowImportance = data.isLowImportance; this._isOwned = data.isOwned; this._isLoading = true; - this._inlines = []; + this._inlines = null; if (data.changesetState) { this._loadChangesetState(data.changesetState); } + JX.enableDispatch(window, 'selectstart'); + var onselect = JX.bind(this, this._onClickHeader); - JX.DOM.listen(this._node, 'mousedown', 'changeset-header', onselect); + JX.DOM.listen( + this._node, + ['mousedown', 'selectstart'], + 'changeset-header', + onselect); }, members: { _node: null, _loaded: false, _sequence: 0, _stabilize: false, _renderURI: null, _ref: null, _rendererKey: null, _highlight: null, - _documentEngine: null, + _requestDocumentEngineKey: null, + _responseDocumentEngineKey: null, + _availableDocumentEngineKeys: null, _characterEncoding: null, _undoTemplates: null, _leftID: null, _rightID: null, _inlines: null, _visible: true, _displayPath: null, _changesetList: null, _icon: null, _editorURI: null, _editorConfigureURI: null, _showPathURI: null, _showDirectoryURI: null, _pathView: null, _pathIconIcon: null, _pathIconColor: null, _isLowImportance: null, _isOwned: null, _isHidden: null, _isSelected: false, _viewMenu: null, getEditorURI: function() { return this._editorURI; }, getEditorConfigureURI: function() { return this._editorConfigureURI; }, getShowPathURI: function() { return this._showPathURI; }, getShowDirectoryURI: function() { return this._showDirectoryURI; }, getLeftChangesetID: function() { return this._leftID; }, getRightChangesetID: function() { return this._rightID; }, setChangesetList: function(list) { this._changesetList = list; return this; }, setViewMenu: function(menu) { this._viewMenu = menu; return this; }, getIcon: function() { if (!this._visible) { return 'fa-file-o'; } return this._icon; }, getColor: function() { if (!this._visible) { return 'grey'; } return 'blue'; }, getChangesetList: function() { return this._changesetList; }, /** * Has the content of this changeset been loaded? * * This method returns `true` if a request has been fired, even if the * response has not returned yet. * * @return bool True if the content has been loaded. */ isLoaded: function() { return this._loaded; }, /** * Configure stabilization of the document position on content load. * * When we dump the changeset into the document, we can try to stabilize * the document scroll position so that the user doesn't feel like they * are jumping around as things load in. This is generally useful when * populating initial changes. * * However, if a user explicitly requests a content load by clicking a * "Load" link or using the dropdown menu, this stabilization generally * feels unnatural, so we don't use it in response to explicit user action. * * @param bool True to stabilize the next content fill. * @return this */ setStabilize: function(stabilize) { this._stabilize = stabilize; return this; }, /** * Should this changeset load immediately when the page loads? * * Normally, changes load immediately, but if a diff or commit is very * large we stop doing this and have the user load files explicitly, or * choose to load everything. * * @return bool True if the changeset should load automatically when the * page loads. */ shouldAutoload: function() { return this._getNodeData().autoload; }, /** * Load this changeset, if it isn't already loading. * * This fires a request to fill the content of this changeset, provided * there isn't already a request in flight. To force a reload, use * @{method:reload}. * * @return this */ load: function() { if (this._loaded) { return this; } return this.reload(); }, /** * Reload the changeset content. * * This method always issues a request, even if the content is already * loading. To load conditionally, use @{method:load}. * * @return this */ reload: function(state) { this._loaded = true; this._sequence++; var workflow = this._newReloadWorkflow(state) .setHandler(JX.bind(this, this._onresponse, this._sequence)); this._startContentWorkflow(workflow); var pht = this.getChangesetList().getTranslations(); JX.DOM.setContent( this._getContentFrame(), JX.$N( 'div', {className: 'differential-loading'}, pht('Loading...'))); return this; }, _newReloadWorkflow: function(state) { var params = this._getViewParameters(state); return new JX.Workflow(this._renderURI, params); }, /** * Load missing context in a changeset. * * We do this when the user clicks "Show X Lines". We also expand all of * the missing context when they "Show All Context". * * @param string Line range specification, like "0-40/0-20". * @param node Row where the context should be rendered after loading. * @param bool True if this is a bulk load of multiple context blocks. * @return this */ loadContext: function(range, target, bulk) { var params = this._getViewParameters(); params.range = range; var pht = this.getChangesetList().getTranslations(); var container = JX.DOM.scry(target, 'td')[0]; JX.DOM.setContent(container, pht('Loading...')); JX.DOM.alterClass(target, 'differential-show-more-loading', true); var workflow = new JX.Workflow(this._renderURI, params) .setHandler(JX.bind(this, this._oncontext, target)); if (bulk) { // If we're loading a bunch of these because the viewer clicked // "Show All Context" or similar, use lower-priority requests // and draw a progress bar. this._startContentWorkflow(workflow); } else { // If this is a single click on a context link, use a higher priority // load without a chrome change. workflow.start(); } return this; }, loadAllContext: function() { var nodes = JX.DOM.scry(this._node, 'tr', 'context-target'); for (var ii = 0; ii < nodes.length; ii++) { var show = JX.DOM.scry(nodes[ii], 'a', 'show-more'); for (var jj = 0; jj < show.length; jj++) { var data = JX.Stratcom.getData(show[jj]); if (data.type != 'all') { continue; } this.loadContext(data.range, nodes[ii], true); } } }, _startContentWorkflow: function(workflow) { var routable = workflow.getRoutable(); routable .setPriority(500) .setType('content') .setKey(this._getRoutableKey()); JX.Router.getInstance().queue(routable); }, getDisplayPath: function() { return this._displayPath; }, /** * Receive a response to a context request. */ _oncontext: function(target, response) { // TODO: This should be better structured. // If the response comes back with several top-level nodes, the last one // is the actual context; the others are headers. Add any headers first, // then copy the new rows into the document. var markup = JX.$H(response.changeset).getFragment(); var len = markup.childNodes.length; var diff = JX.DOM.findAbove(target, 'table', 'differential-diff'); for (var ii = 0; ii < len - 1; ii++) { diff.parentNode.insertBefore(markup.firstChild, diff); } var table = markup.firstChild; var root = target.parentNode; this._moveRows(table, root, target); root.removeChild(target); this._onchangesetresponse(response); }, _moveRows: function(src, dst, before) { var rows = JX.DOM.scry(src, 'tr'); for (var ii = 0; ii < rows.length; ii++) { // Find the table this belongs to. If it's a sub-table, like a // table in an inline comment, don't copy it. if (JX.DOM.findAbove(rows[ii], 'table') !== src) { continue; } if (before) { dst.insertBefore(rows[ii], before); } else { dst.appendChild(rows[ii]); } } }, /** * Get parameters which define the current rendering options. */ _getViewParameters: function(state) { var parameters = { ref: this._ref, device: this._getDefaultDeviceRenderer() }; if (state) { JX.copy(parameters, state); } return parameters; }, /** * Get the active @{class:JX.Routable} for this changeset. * * After issuing a request with @{method:load} or @{method:reload}, you * can adjust routable settings (like priority) by querying the routable * with this method. Note that there may not be a current routable. * * @return JX.Routable|null Active routable, if one exists. */ getRoutable: function() { return JX.Router.getInstance().getRoutableByKey(this._getRoutableKey()); }, getRendererKey: function() { return this._rendererKey; }, _getDefaultDeviceRenderer: function() { // NOTE: If you load the page at one device resolution and then resize to // a different one we don't re-render the diffs, because it's a // complicated mess and you could lose inline comments, cursor positions, // etc. return (JX.Device.getDevice() == 'desktop') ? '2up' : '1up'; }, getUndoTemplates: function() { return this._undoTemplates; }, getCharacterEncoding: function() { return this._characterEncoding; }, getHighlight: function() { return this._highlight; }, - getDocumentEngine: function(engine) { - return this._documentEngine; + getRequestDocumentEngineKey: function() { + return this._requestDocumentEngineKey; + }, + + getResponseDocumentEngineKey: function() { + return this._responseDocumentEngineKey; + }, + + getAvailableDocumentEngineKeys: function() { + return this._availableDocumentEngineKeys; }, getSelectableItems: function() { var items = []; items.push({ type: 'file', changeset: this, target: this, nodes: { begin: this._node, end: null } }); if (!this._visible) { return items; } var rows = JX.DOM.scry(this._node, 'tr'); var blocks = []; var block; var ii; for (ii = 0; ii < rows.length; ii++) { var type = this._getRowType(rows[ii]); if (!block || (block.type !== type)) { block = { type: type, items: [] }; blocks.push(block); } block.items.push(rows[ii]); } var last_inline = null; var last_inline_item = null; for (ii = 0; ii < blocks.length; ii++) { block = blocks[ii]; if (block.type == 'change') { items.push({ type: block.type, changeset: this, target: block.items[0], nodes: { begin: block.items[0], end: block.items[block.items.length - 1] } }); } if (block.type == 'comment') { for (var jj = 0; jj < block.items.length; jj++) { var inline = this.getInlineForRow(block.items[jj]); // When comments are being edited, they have a hidden row with // the actual comment and then a visible row with the editor. // In this case, we only want to generate one item, but it should // use the editor as a scroll target. To accomplish this, check if // this row has the same inline as the previous row. If so, update // the last item to use this row's nodes. if (inline === last_inline) { last_inline_item.nodes.begin = block.items[jj]; last_inline_item.nodes.end = block.items[jj]; continue; } else { last_inline = inline; } var is_saved = (!inline.isDraft() && !inline.isEditing()); last_inline_item = { type: block.type, changeset: this, target: inline, hidden: inline.isHidden(), collapsed: inline.isCollapsed(), deleted: !inline.getID() && !inline.isEditing(), nodes: { begin: block.items[jj], end: block.items[jj] }, attributes: { unsaved: inline.isEditing(), anyDraft: inline.isDraft() || inline.isDraftDone(), undone: (is_saved && !inline.isDone()), done: (is_saved && inline.isDone()) } }; items.push(last_inline_item); } } } return items; }, _getRowType: function(row) { // NOTE: Don't do "className.indexOf()" elsewhere. This is evil legacy // magic. if (row.className.indexOf('inline') !== -1) { return 'comment'; } var cells = JX.DOM.scry(row, 'td'); for (var ii = 0; ii < cells.length; ii++) { if (cells[ii].className.indexOf('old') !== -1 || cells[ii].className.indexOf('new') !== -1) { return 'change'; } } }, _getNodeData: function() { return JX.Stratcom.getData(this._node); }, getVectors: function() { return { pos: JX.$V(this._node), dim: JX.Vector.getDim(this._node) }; }, _onresponse: function(sequence, response) { if (sequence != this._sequence) { // If this isn't the most recent request, ignore it. This normally // means the user changed view settings between the time the page loaded // and the content filled. return; } // As we populate the changeset list, we try to hold the document scroll // position steady, so that, e.g., users who want to leave a comment on a // diff with a large number of changes don't constantly have the text // area scrolled off the bottom of the screen until the entire diff loads. // // There are several major cases here: // // - If we're near the top of the document, never scroll. // - If we're near the bottom of the document, always scroll, unless // we have an anchor. // - Otherwise, scroll if the changes were above (or, at least, // almost entirely above) the viewport. // // We don't scroll if the changes were just near the top of the viewport // because this makes us scroll incorrectly when an anchored change is // visible. See T12779. var target = this._node; var old_pos = JX.Vector.getScroll(); var old_view = JX.Vector.getViewport(); var old_dim = JX.Vector.getDocument(); // Number of pixels away from the top or bottom of the document which // count as "nearby". var sticky = 480; var near_top = (old_pos.y <= sticky); var near_bot = ((old_pos.y + old_view.y) >= (old_dim.y - sticky)); // If we have an anchor in the URL, never stick to the bottom of the // page. See T11784 for discussion. if (window.location.hash) { near_bot = false; } var target_pos = JX.Vector.getPos(target); var target_dim = JX.Vector.getDim(target); var target_bot = (target_pos.y + target_dim.y); // Detect if the changeset is entirely (or, at least, almost entirely) // above us. The height here is roughly the height of the persistent // banner. var above_screen = (target_bot < old_pos.y + 64); // If we have a URL anchor and are currently nearby, stick to it // no matter what. var on_target = null; if (window.location.hash) { try { var anchor = JX.$(window.location.hash.replace('#', '')); if (anchor) { var anchor_pos = JX.$V(anchor); if ((anchor_pos.y > old_pos.y) && (anchor_pos.y < old_pos.y + 96)) { on_target = anchor; } } } catch (ignored) { // If we have a bogus anchor, just ignore it. } } var frame = this._getContentFrame(); JX.DOM.setContent(frame, JX.$H(response.changeset)); if (this._stabilize) { if (on_target) { JX.DOM.scrollToPosition(old_pos.x, JX.$V(on_target).y - 60); } else if (!near_top) { if (near_bot || above_screen) { // Figure out how much taller the document got. var delta = (JX.Vector.getDocument().y - old_dim.y); JX.DOM.scrollToPosition(old_pos.x, old_pos.y + delta); } } this._stabilize = false; } this._onchangesetresponse(response); }, _onchangesetresponse: function(response) { // Code shared by autoload and context responses. this._loadChangesetState(response); JX.Stratcom.invoke('differential-inline-comment-refresh'); this._rebuildAllInlines(); JX.Stratcom.invoke('resize'); }, _loadChangesetState: function(state) { if (state.coverage) { for (var k in state.coverage) { try { JX.DOM.replace(JX.$(k), JX.$H(state.coverage[k])); } catch (ignored) { // Not terribly important. } } } if (state.undoTemplates) { this._undoTemplates = state.undoTemplates; } this._rendererKey = state.rendererKey; this._highlight = state.highlight; this._characterEncoding = state.characterEncoding; - this._documentEngine = state.documentEngine; + this._requestDocumentEngineKey = state.requestDocumentEngineKey; + this._responseDocumentEngineKey = state.responseDocumentEngineKey; + this._availableDocumentEngineKeys = state.availableDocumentEngineKeys; this._isHidden = state.isHidden; var is_hidden = !this.isVisible(); if (this._isHidden != is_hidden) { this.setVisible(!this._isHidden); } this._isLoading = false; this.getPathView().setIsLoading(this._isLoading); }, _getContentFrame: function() { return JX.DOM.find(this._node, 'div', 'changeset-view-content'); }, _getRoutableKey: function() { return 'changeset-view.' + this._ref + '.' + this._sequence; }, getInlineForRow: function(node) { var data = JX.Stratcom.getData(node); if (!data.inline) { var inline = new JX.DiffInline() .setChangeset(this) .bindToRow(node); this._inlines.push(inline); } return data.inline; }, - newInlineForRange: function(origin, target) { + newInlineForRange: function(origin, target, options) { var list = this.getChangesetList(); var src = list.getLineNumberFromHeader(origin); var dst = list.getLineNumberFromHeader(target); var changeset_id = null; var side = list.getDisplaySideFromHeader(origin); if (side == 'right') { changeset_id = this.getRightChangesetID(); } else { changeset_id = this.getLeftChangesetID(); } var is_new = false; if (side == 'right') { is_new = true; } else if (this.getRightChangesetID() != this.getLeftChangesetID()) { is_new = true; } var data = { origin: origin, target: target, number: src, length: dst - src, changesetID: changeset_id, displaySide: side, isNewFile: is_new }; + JX.copy(data, options || {}); + var inline = new JX.DiffInline() .setChangeset(this) .bindToRange(data); this._inlines.push(inline); inline.create(); return inline; }, newInlineReply: function(original, text) { var inline = new JX.DiffInline() .setChangeset(this) .bindToReply(original); this._inlines.push(inline); inline.create(text); return inline; }, getInlineByID: function(id) { return this._queryInline('id', id); }, getInlineByPHID: function(phid) { return this._queryInline('phid', phid); }, _queryInline: function(field, value) { // First, look for the inline in the objects we've already built. var inline = this._findInline(field, value); if (inline) { return inline; } // If we haven't found a matching inline yet, rebuild all the inlines // present in the document, then look again. this._rebuildAllInlines(); return this._findInline(field, value); }, _findInline: function(field, value) { for (var ii = 0; ii < this._inlines.length; ii++) { var inline = this._inlines[ii]; var target; switch (field) { case 'id': target = inline.getID(); break; case 'phid': target = inline.getPHID(); break; } if (target == value) { return inline; } } return null; }, getInlines: function() { - this._rebuildAllInlines(); + if (this._inlines === null) { + this._rebuildAllInlines(); + } + return this._inlines; }, _rebuildAllInlines: function() { + if (this._inlines === null) { + this._inlines = []; + } + var rows = JX.DOM.scry(this._node, 'tr'); var ii; for (ii = 0; ii < rows.length; ii++) { var row = rows[ii]; if (this._getRowType(row) != 'comment') { continue; } // As a side effect, this builds any missing inline objects and adds // them to this Changeset's list of inlines. this.getInlineForRow(row); } }, redrawFileTree: function() { var inlines = this._inlines; var done = []; var undone = []; var inline; for (var ii = 0; ii < inlines.length; ii++) { inline = inlines[ii]; if (inline.isDeleted()) { continue; } if (inline.isUndo()) { continue; } if (inline.isSynthetic()) { continue; } if (inline.isEditing()) { continue; } if (!inline.getID()) { // These are new comments which have been cancelled, and do not // count as anything. continue; } if (inline.isDraft()) { continue; } if (!inline.isDone()) { undone.push(inline); } else { done.push(inline); } } var total = done.length + undone.length; var hint; var is_visible; var is_completed; if (total) { if (done.length) { hint = [done.length, '/', total]; } else { hint = total; } is_visible = true; is_completed = (done.length == total); } else { hint = '-'; is_visible = false; is_completed = false; } var node = this.getPathView().getInlineNode(); JX.DOM.setContent(node, hint); JX.DOM.alterClass(node, 'diff-tree-path-inlines-visible', is_visible); JX.DOM.alterClass(node, 'diff-tree-path-inlines-completed', is_completed); }, _onClickHeader: function(e) { // If the user clicks the actual path name text, don't count this as // a selection action: we want to let them select the path. var path_name = e.getNode('changeset-header-path-name'); if (path_name) { return; } - e.prevent(); + // Don't allow repeatedly clicking a header to begin a "select word" or + // "select line" operation. + if (e.getType() === 'selectstart') { + e.kill(); + return; + } + + // NOTE: Don't prevent or kill the event. If the user has text selected, + // clicking a header should clear the selection (and dismiss any inline + // context menu, if one exists) as clicking elsewhere in the document + // normally would. if (this._isSelected) { this.getChangesetList().selectChangeset(null); } else { this.select(false); } }, toggleVisibility: function() { this.setVisible(!this._visible); var attrs = { hidden: this.isVisible() ? 0 : 1, discard: 1 }; var workflow = this._newReloadWorkflow(attrs) .setHandler(JX.bag); this._startContentWorkflow(workflow); }, setVisible: function(visible) { this._visible = visible; var diff = this._getDiffNode(); var options = this._getViewButtonNode(); var show = this._getShowButtonNode(); if (this._visible) { JX.DOM.show(diff); JX.DOM.show(options); JX.DOM.hide(show); } else { JX.DOM.hide(diff); JX.DOM.hide(options); JX.DOM.show(show); if (this._viewMenu) { this._viewMenu.close(); } } JX.Stratcom.invoke('resize'); var node = this._node; JX.DOM.alterClass(node, 'changeset-content-hidden', !this._visible); this.getPathView().setIsHidden(!this._visible); }, setIsSelected: function(is_selected) { this._isSelected = !!is_selected; var node = this._node; JX.DOM.alterClass(node, 'changeset-selected', this._isSelected); return this; }, _getDiffNode: function() { if (!this._diffNode) { this._diffNode = JX.DOM.find(this._node, 'table', 'differential-diff'); } return this._diffNode; }, _getViewButtonNode: function() { if (!this._viewButtonNode) { this._viewButtonNode = JX.DOM.find( this._node, 'a', 'differential-view-options'); } return this._viewButtonNode; }, _getShowButtonNode: function() { if (!this._showButtonNode) { var pht = this.getChangesetList().getTranslations(); var show_button = new JX.PHUIXButtonView() .setIcon('fa-angle-double-down') .setText(pht('Show Changeset')) .setColor('grey'); var button_node = show_button.getNode(); this._getViewButtonNode().parentNode.appendChild(button_node); var onshow = JX.bind(this, this._onClickShowButton); JX.DOM.listen(button_node, 'click', null, onshow); this._showButtonNode = button_node; } return this._showButtonNode; }, _onClickShowButton: function(e) { e.prevent(); // We're always showing the changeset, but want to make sure the state // change is persisted on the server. this.toggleVisibility(); }, isVisible: function() { return this._visible; }, getPathView: function() { if (!this._pathView) { var view = new JX.DiffPathView() .setChangeset(this) .setPath(this._pathParts) .setIsLowImportance(this._isLowImportance) .setIsOwned(this._isOwned) .setIsLoading(this._isLoading); view.getIcon() .setIcon(this._pathIconIcon) .setColor(this._pathIconColor); this._pathView = view; } return this._pathView; }, select: function(scroll) { this.getChangesetList().selectChangeset(this, scroll); return this; } }, statics: { getForNode: function(node) { var data = JX.Stratcom.getData(node); if (!data.changesetViewManager) { data.changesetViewManager = new JX.DiffChangeset(node); } return data.changesetViewManager; } } }); diff --git a/webroot/rsrc/js/application/diff/DiffChangesetList.js b/webroot/rsrc/js/application/diff/DiffChangesetList.js index f7ebb1bf85..e5a6095da2 100644 --- a/webroot/rsrc/js/application/diff/DiffChangesetList.js +++ b/webroot/rsrc/js/application/diff/DiffChangesetList.js @@ -1,2220 +1,2788 @@ /** * @provides phabricator-diff-changeset-list * @requires javelin-install * phuix-button-view * phabricator-diff-tree-view * @javelin */ JX.install('DiffChangesetList', { construct: function() { this._changesets = []; var onload = JX.bind(this, this._ifawake, this._onload); JX.Stratcom.listen('click', 'differential-load', onload); var onmore = JX.bind(this, this._ifawake, this._onmore); JX.Stratcom.listen('click', 'show-more', onmore); var onmenu = JX.bind(this, this._ifawake, this._onmenu); JX.Stratcom.listen('click', 'differential-view-options', onmenu); - var oncollapse = JX.bind(this, this._ifawake, this._oncollapse, true); - JX.Stratcom.listen('click', 'hide-inline', oncollapse); - var onexpand = JX.bind(this, this._ifawake, this._oncollapse, false); JX.Stratcom.listen('click', 'reveal-inline', onexpand); var onresize = JX.bind(this, this._ifawake, this._onresize); JX.Stratcom.listen('resize', null, onresize); var onscroll = JX.bind(this, this._ifawake, this._onscroll); JX.Stratcom.listen('scroll', null, onscroll); + JX.enableDispatch(window, 'selectstart'); + var onselect = JX.bind(this, this._ifawake, this._onselect); JX.Stratcom.listen( - 'mousedown', + ['mousedown', 'selectstart'], ['differential-inline-comment', 'differential-inline-header'], onselect); var onhover = JX.bind(this, this._ifawake, this._onhover); JX.Stratcom.listen( ['mouseover', 'mouseout'], 'differential-inline-comment', onhover); var onrangedown = JX.bind(this, this._ifawake, this._onrangedown); JX.Stratcom.listen( 'mousedown', ['differential-changeset', 'tag:td'], onrangedown); var onrangemove = JX.bind(this, this._ifawake, this._onrangemove); JX.Stratcom.listen( ['mouseover', 'mouseout'], ['differential-changeset', 'tag:td'], onrangemove); var onrangeup = JX.bind(this, this._ifawake, this._onrangeup); JX.Stratcom.listen( 'mouseup', null, onrangeup); + var onrange = JX.bind(this, this._ifawake, this._onSelectRange); + JX.enableDispatch(window, 'selectionchange'); + JX.Stratcom.listen('selectionchange', null, onrange); + this._setupInlineCommentListeners(); }, properties: { translations: null, inlineURI: null, inlineListURI: null, isStandalone: false, formationView: null }, members: { _initialized: false, _asleep: true, _changesets: null, _cursorItem: null, _focusNode: null, _focusStart: null, _focusEnd: null, - _hoverNode: null, _hoverInline: null, _hoverOrigin: null, _hoverTarget: null, _rangeActive: false, _rangeOrigin: null, _rangeTarget: null, _bannerNode: null, _unsavedButton: null, _unsubmittedButton: null, _doneButton: null, _doneMode: null, _dropdownMenu: null, _menuButton: null, _menuItems: null, _selectedChangeset: null, sleep: function() { this._asleep = true; this._redrawFocus(); this._redrawSelection(); this.resetHover(); this._bannerChangeset = null; this._redrawBanner(); }, wake: function() { this._asleep = false; this._redrawFocus(); this._redrawSelection(); this._bannerChangeset = null; this._redrawBanner(); this._redrawFiletree(); if (this._initialized) { return; } this._initialized = true; var pht = this.getTranslations(); // We may be viewing the normal "/D123" view (with all the changesets) // or the standalone view (with just one changeset). In the standalone // view, some options (like jumping to next or previous file) do not // make sense and do not function. var standalone = this.getIsStandalone(); var label; if (!standalone) { label = pht('Jump to the table of contents.'); this._installKey('t', 'diff-nav', label, this._ontoc); label = pht('Jump to the comment area.'); this._installKey('x', 'diff-nav', label, this._oncomments); } label = pht('Jump to next change.'); this._installJumpKey('j', label, 1); label = pht('Jump to previous change.'); this._installJumpKey('k', label, -1); if (!standalone) { label = pht('Jump to next file.'); this._installJumpKey('J', label, 1, 'file'); label = pht('Jump to previous file.'); this._installJumpKey('K', label, -1, 'file'); } label = pht('Jump to next inline comment.'); this._installJumpKey('n', label, 1, 'comment'); label = pht('Jump to previous inline comment.'); this._installJumpKey('p', label, -1, 'comment'); label = pht('Jump to next inline comment, including collapsed comments.'); this._installJumpKey('N', label, 1, 'comment', true); label = pht( 'Jump to previous inline comment, including collapsed comments.'); this._installJumpKey('P', label, -1, 'comment', true); var formation = this.getFormationView(); if (formation) { var filetree = formation.getColumn(0); var toggletree = JX.bind(filetree, filetree.toggleVisibility); label = pht('Hide or show the paths panel.'); this._installKey('f', 'diff-vis', label, toggletree); } if (!standalone) { label = pht('Hide or show the current changeset.'); this._installKey('h', 'diff-vis', label, this._onkeytogglefile); } label = pht('Reply to selected inline comment or change.'); this._installKey('r', 'inline', label, JX.bind(this, this._onkeyreply, false)); label = pht('Reply and quote selected inline comment.'); this._installKey('R', 'inline', label, JX.bind(this, this._onkeyreply, true)); + label = pht('Add new inline comment on selected source text.'); + this._installKey('c', 'inline', label, + JX.bind(this, this._onKeyCreate)); + label = pht('Edit selected inline comment.'); this._installKey('e', 'inline', label, this._onkeyedit); label = pht('Mark or unmark selected inline comment as done.'); this._installKey('w', 'inline', label, this._onkeydone); label = pht('Collapse or expand inline comment.'); this._installKey('q', 'diff-vis', label, this._onkeycollapse); label = pht('Hide or show all inline comments.'); this._installKey('A', 'diff-vis', label, this._onkeyhideall); label = pht('Show path in repository.'); this._installKey('d', 'diff-nav', label, this._onkeyshowpath); label = pht('Show directory in repository.'); this._installKey('D', 'diff-nav', label, this._onkeyshowdirectory); label = pht('Open file in external editor.'); this._installKey('\\', 'diff-nav', label, this._onkeyopeneditor); }, isAsleep: function() { return this._asleep; }, newChangesetForNode: function(node) { var changeset = JX.DiffChangeset.getForNode(node); this._changesets.push(changeset); changeset.setChangesetList(this); return changeset; }, getChangesetForNode: function(node) { return JX.DiffChangeset.getForNode(node); }, getInlineByID: function(id) { var inline = null; for (var ii = 0; ii < this._changesets.length; ii++) { inline = this._changesets[ii].getInlineByID(id); if (inline) { break; } } return inline; }, _ifawake: function(f) { // This function takes another function and only calls it if the // changeset list is awake, so we basically just ignore events when we // are asleep. This may move up the stack at some point as we do more // with Quicksand/Sheets. if (this.isAsleep()) { return; } return f.apply(this, [].slice.call(arguments, 1)); }, _onload: function(e) { var data = e.getNodeData('differential-load'); // NOTE: We can trigger a load from either an explicit "Load" link on // the changeset, or by clicking a link in the table of contents. If // the event was a table of contents link, we let the anchor behavior // run normally. if (data.kill) { e.kill(); } var node = JX.$(data.id); var changeset = this.getChangesetForNode(node); changeset.load(); // TODO: Move this into Changeset. var routable = changeset.getRoutable(); if (routable) { routable.setPriority(2000); } }, _installKey: function(key, group, label, handler) { handler = JX.bind(this, this._ifawake, handler); return new JX.KeyboardShortcut(key, label) .setHandler(handler) .setGroup(group) .register(); }, _installJumpKey: function(key, label, delta, filter, show_collapsed) { filter = filter || null; var options = { filter: filter, collapsed: show_collapsed }; var handler = JX.bind(this, this._onjumpkey, delta, options); return this._installKey(key, 'diff-nav', label, handler); }, _ontoc: function(manager) { var toc = JX.$('toc'); manager.scrollTo(toc); }, _oncomments: function(manager) { var reply = JX.$('reply'); manager.scrollTo(reply); }, getSelectedInline: function() { var cursor = this._cursorItem; if (cursor) { if (cursor.type == 'comment') { return cursor.target; } } return null; }, _onkeyreply: function(is_quote) { var cursor = this._cursorItem; if (cursor) { if (cursor.type == 'comment') { var inline = cursor.target; if (inline.canReply()) { this.setFocus(null); - - var text; - if (is_quote) { - text = inline.getRawText(); - text = '> ' + text.replace(/\n/g, '\n> ') + '\n\n'; - } else { - text = ''; - } - - inline.reply(text); + inline.reply(true); return; } } // If the keyboard cursor is selecting a range of lines, we may have // a mixture of old and new changes on the selected rows. It is not // entirely unambiguous what the user means when they say they want // to reply to this, but we use this logic: reply on the new file if // there are any new lines. Otherwise (if there are only removed // lines) reply on the old file. if (cursor.type == 'change') { var origin = cursor.nodes.begin; var target = cursor.nodes.end; // The "origin" and "target" are entire rows, but we need to find // a range of "" nodes to actually create an inline, so go // fishing. var old_list = []; var new_list = []; var row = origin; while (row) { var header = row.firstChild; while (header) { if (this.getLineNumberFromHeader(header)) { if (header.className.indexOf('old') !== -1) { old_list.push(header); } else if (header.className.indexOf('new') !== -1) { new_list.push(header); } } header = header.nextSibling; } if (row == target) { break; } row = row.nextSibling; } var use_list; if (new_list.length) { use_list = new_list; } else { use_list = old_list; } var src = use_list[0]; var dst = use_list[use_list.length - 1]; cursor.changeset.newInlineForRange(src, dst); this.setFocus(null); return; } } var pht = this.getTranslations(); this._warnUser(pht('You must select a comment or change to reply to.')); }, _onkeyedit: function() { var cursor = this._cursorItem; if (cursor) { if (cursor.type == 'comment') { var inline = cursor.target; if (inline.canEdit()) { this.setFocus(null); inline.edit(); return; } } } var pht = this.getTranslations(); this._warnUser(pht('You must select a comment to edit.')); }, + _onKeyCreate: function() { + var start = this._sourceSelectionStart; + var end = this._sourceSelectionEnd; + + if (!this._sourceSelectionStart) { + var pht = this.getTranslations(); + this._warnUser( + pht( + 'You must select source text to create a new inline comment.')); + return; + } + + this._setSourceSelection(null, null); + + var changeset = start.changeset; + + var config = {}; + if (changeset.getResponseDocumentEngineKey() === null) { + // If the changeset is using a document renderer, we ignore the + // selection range and just treat this as a comment from the first + // block to the last block. + + // If we don't discard the range, we later render a bogus highlight + // if the block content is complex (like a Jupyter notebook cell + // with images). + + config.startOffset = start.offset; + config.endOffset = end.offset; + } + + changeset.newInlineForRange(start.targetNode, end.targetNode, config); + }, + _onkeydone: function() { var cursor = this._cursorItem; if (cursor) { if (cursor.type == 'comment') { var inline = cursor.target; if (inline.canDone()) { this.setFocus(null); inline.toggleDone(); return; } } } var pht = this.getTranslations(); this._warnUser(pht('You must select a comment to mark done.')); }, _onkeytogglefile: function() { var pht = this.getTranslations(); var changeset = this._getChangesetForKeyCommand(); if (!changeset) { this._warnUser(pht('You must select a file to hide or show.')); return; } changeset.toggleVisibility(); }, _getChangesetForKeyCommand: function() { var cursor = this._cursorItem; var changeset; if (cursor) { changeset = cursor.changeset; } if (!changeset) { changeset = this._getVisibleChangeset(); } return changeset; }, _onkeyopeneditor: function() { var pht = this.getTranslations(); var changeset = this._getChangesetForKeyCommand(); if (!changeset) { this._warnUser(pht('You must select a file to edit.')); return; } var editor_uri = changeset.getEditorURI(); if (editor_uri === null) { this._warnUser(pht('No external editor is configured.')); return; } JX.$U(editor_uri).go(); }, _onkeyshowpath: function() { this._onrepositorykey(false); }, _onkeyshowdirectory: function() { this._onrepositorykey(true); }, _onrepositorykey: function(is_directory) { var pht = this.getTranslations(); var changeset = this._getChangesetForKeyCommand(); if (!changeset) { this._warnUser(pht('You must select a file to open.')); return; } var show_uri; if (is_directory) { show_uri = changeset.getShowDirectoryURI(); } else { show_uri = changeset.getShowPathURI(); } if (show_uri === null) { return; } window.open(show_uri); }, _onkeycollapse: function() { var cursor = this._cursorItem; if (cursor) { if (cursor.type == 'comment') { var inline = cursor.target; if (inline.canCollapse()) { this.setFocus(null); inline.setCollapsed(!inline.isCollapsed()); return; } } } var pht = this.getTranslations(); this._warnUser(pht('You must select a comment to hide.')); }, _onkeyhideall: function() { var inlines = this._getInlinesByType(); if (inlines.visible.length) { this._toggleInlines('all'); } else { this._toggleInlines('show'); } }, _warnUser: function(message) { new JX.Notification() .setContent(message) .alterClassName('jx-notification-alert', true) .setDuration(3000) .show(); }, _onjumpkey: function(delta, options) { var state = this._getSelectionState(); var filter = options.filter || null; var collapsed = options.collapsed || false; var wrap = options.wrap || false; var attribute = options.attribute || null; var show = options.show || false; var cursor = state.cursor; var items = state.items; // If there's currently no selection and the user tries to go back, // don't do anything. if ((cursor === null) && (delta < 0)) { return; } var did_wrap = false; while (true) { if (cursor === null) { cursor = 0; } else { cursor = cursor + delta; } // If we've gone backward past the first change, bail out. if (cursor < 0) { return; } // If we've gone forward off the end of the list, figure out where we // should end up. if (cursor >= items.length) { if (!wrap) { // If we aren't wrapping around, we're done. return; } if (did_wrap) { // If we're already wrapped around, we're done. return; } // Otherwise, wrap the cursor back to the top. cursor = 0; did_wrap = true; } // If we're selecting things of a particular type (like only files) // and the next item isn't of that type, move past it. if (filter !== null) { if (items[cursor].type !== filter) { continue; } } // If the item is collapsed, don't select it when iterating with jump // keys. It can still potentially be selected in other ways. if (!collapsed) { if (items[cursor].collapsed) { continue; } } // If the item has been deleted, don't select it when iterating. The // cursor may remain on it until it is removed. if (items[cursor].deleted) { continue; } // If we're selecting things with a particular attribute, like // "unsaved", skip items without the attribute. if (attribute !== null) { if (!(items[cursor].attributes || {})[attribute]) { continue; } } // If this item is a hidden inline but we're clicking a button which // selects inlines of a particular type, make it visible again. if (items[cursor].hidden) { if (!show) { continue; } items[cursor].target.setHidden(false); } // Otherwise, we've found a valid item to select. break; } this._setSelectionState(items[cursor], true); }, _getSelectionState: function() { var items = this._getSelectableItems(); var cursor = null; if (this._cursorItem !== null) { for (var ii = 0; ii < items.length; ii++) { var item = items[ii]; if (this._cursorItem.target === item.target) { cursor = ii; break; } } } return { cursor: cursor, items: items }; }, selectChangeset: function(changeset, scroll) { var items = this._getSelectableItems(); var cursor = null; for (var ii = 0; ii < items.length; ii++) { var item = items[ii]; if (changeset === item.target) { cursor = ii; break; } } if (cursor !== null) { this._setSelectionState(items[cursor], scroll); } else { this._setSelectionState(null, false); } return this; }, _setSelectionState: function(item, scroll) { + var old = this._cursorItem; + + if (old) { + if (old.type === 'comment') { + old.target.setIsSelected(false); + } + } + this._cursorItem = item; + + if (item) { + if (item.type === 'comment') { + item.target.setIsSelected(true); + } + } + this._redrawSelection(scroll); return this; }, _redrawSelection: function(scroll) { var cursor = this._cursorItem; if (!cursor) { this.setFocus(null); return; } // If this item has been removed from the document (for example: create // a new empty comment, then use the "Unsaved" button to select it, then // cancel it), we can still keep the cursor here but do not want to show // a selection reticle over an invisible node. if (cursor.deleted) { this.setFocus(null); return; } var changeset = cursor.changeset; var tree = this._getTreeView(); if (changeset) { tree.setSelectedPath(cursor.changeset.getPathView()); } else { tree.setSelectedPath(null); } this._selectChangeset(changeset); this.setFocus(cursor.nodes.begin, cursor.nodes.end); if (scroll) { var pos = JX.$V(cursor.nodes.begin); JX.DOM.scrollToPosition(0, pos.y - 60); } return this; }, redrawCursor: function() { // NOTE: This is setting the cursor to the current cursor. Usually, this // would have no effect. // However, if the old cursor pointed at an inline and the inline has // been edited so the rows have changed, this updates the cursor to point // at the new inline with the proper rows for the current state, and // redraws the reticle correctly. var state = this._getSelectionState(); if (state.cursor !== null) { this._setSelectionState(state.items[state.cursor], false); } }, _getSelectableItems: function() { var result = []; for (var ii = 0; ii < this._changesets.length; ii++) { var items = this._changesets[ii].getSelectableItems(); for (var jj = 0; jj < items.length; jj++) { result.push(items[jj]); } } return result; }, _onhover: function(e) { if (e.getIsTouchEvent()) { return; } var inline; if (e.getType() == 'mouseout') { inline = null; } else { inline = this._getInlineForEvent(e); } this._setHoverInline(inline); }, _onmore: function(e) { e.kill(); var node = e.getNode('differential-changeset'); var changeset = this.getChangesetForNode(node); var data = e.getNodeData('show-more'); var target = e.getNode('context-target'); changeset.loadContext(data.range, target); }, _onmenu: function(e) { var button = e.getNode('differential-view-options'); var data = JX.Stratcom.getData(button); if (data.menu) { // We've already built this menu, so we can let the menu itself handle // the event. return; } e.prevent(); var pht = this.getTranslations(); var node = JX.DOM.findAbove( button, 'div', 'differential-changeset'); var changeset_list = this; var changeset = this.getChangesetForNode(node); var menu = new JX.PHUIXDropdownMenu(button) .setWidth(240); var list = new JX.PHUIXActionListView(); var add_link = function(icon, name, href, local) { var link = new JX.PHUIXActionView() .setIcon(icon) .setName(name) .setHandler(function(e) { if (local) { window.location.assign(href); } else { window.open(href); } menu.close(); e.prevent(); }); if (href) { link.setHref(href); } else { link .setDisabled(true) .setUnresponsive(true); } list.addItem(link); return link; }; var visible_item = new JX.PHUIXActionView() .setKeyCommand('h') .setHandler(function(e) { e.prevent(); menu.close(); changeset.select(false); changeset.toggleVisibility(); }); list.addItem(visible_item); var reveal_item = new JX.PHUIXActionView() .setIcon('fa-eye'); list.addItem(reveal_item); list.addItem( new JX.PHUIXActionView() .setDivider(true)); var up_item = new JX.PHUIXActionView() .setHandler(function(e) { if (changeset.isLoaded()) { // Don't let the user swap display modes if a comment is being // edited, since they might lose their work. See PHI180. var inlines = changeset.getInlines(); for (var ii = 0; ii < inlines.length; ii++) { if (inlines[ii].isEditing()) { changeset_list._warnUser( pht( 'Finish editing inline comments before changing display ' + 'modes.')); e.prevent(); menu.close(); return; } } var renderer = changeset.getRendererKey(); if (renderer == '1up') { renderer = '2up'; } else { renderer = '1up'; } changeset.reload({renderer: renderer}); } else { changeset.reload(); } e.prevent(); menu.close(); }); list.addItem(up_item); var encoding_item = new JX.PHUIXActionView() .setIcon('fa-font') .setName(pht('Change Text Encoding...')) .setHandler(function(e) { var params = { encoding: changeset.getCharacterEncoding() }; new JX.Workflow('/services/encoding/', params) .setHandler(function(r) { changeset.reload({encoding: r.encoding}); }) .start(); e.prevent(); menu.close(); }); list.addItem(encoding_item); var highlight_item = new JX.PHUIXActionView() .setIcon('fa-sun-o') .setName(pht('Highlight As...')) .setHandler(function(e) { var params = { highlight: changeset.getHighlight() }; new JX.Workflow('/services/highlight/', params) .setHandler(function(r) { changeset.reload({highlight: r.highlight}); }) .start(); e.prevent(); menu.close(); }); list.addItem(highlight_item); var engine_item = new JX.PHUIXActionView() .setIcon('fa-file-image-o') .setName(pht('View As Document Type...')) .setHandler(function(e) { + var options = changeset.getAvailableDocumentEngineKeys() || []; + options = options.join(','); + var params = { - engine: changeset.getDocumentEngine(), + engine: changeset.getResponseDocumentEngineKey(), + options: options }; new JX.Workflow('/services/viewas/', params) .setHandler(function(r) { changeset.reload({engine: r.engine}); }) .start(); e.prevent(); menu.close(); }); list.addItem(engine_item); list.addItem( new JX.PHUIXActionView() .setDivider(true)); add_link('fa-external-link', pht('View Standalone'), data.standaloneURI); add_link('fa-arrow-left', pht('Show Raw File (Left)'), data.leftURI); add_link('fa-arrow-right', pht('Show Raw File (Right)'), data.rightURI); add_link( 'fa-folder-open-o', pht('Show Directory in Repository'), changeset.getShowDirectoryURI()) .setKeyCommand('D'); add_link( 'fa-file-text-o', pht('Show Path in Repository'), changeset.getShowPathURI()) .setKeyCommand('d'); var editor_uri = changeset.getEditorURI(); if (editor_uri !== null) { add_link('fa-i-cursor', pht('Open in Editor'), editor_uri, true) .setKeyCommand('\\'); } else { var configure_uri = changeset.getEditorConfigureURI(); if (configure_uri !== null) { add_link('fa-wrench', pht('Configure Editor'), configure_uri); } } menu.setContent(list.getNode()); menu.listen('open', function() { // When the user opens the menu, check if there are any "Show More" // links in the changeset body. If there aren't, disable the "Show // Entire File" menu item since it won't change anything. var nodes = JX.DOM.scry(JX.$(data.containerID), 'a', 'show-more'); if (nodes.length) { reveal_item .setDisabled(false) .setName(pht('Show All Context')) .setIcon('fa-arrows-v') .setHandler(function(e) { changeset.loadAllContext(); e.prevent(); menu.close(); }); } else { reveal_item .setDisabled(true) .setUnresponsive(true) .setIcon('fa-file') .setName(pht('All Context Shown')) .setHref(null); } encoding_item.setDisabled(!changeset.isLoaded()); highlight_item.setDisabled(!changeset.isLoaded()); engine_item.setDisabled(!changeset.isLoaded()); if (changeset.isLoaded()) { if (changeset.getRendererKey() == '2up') { up_item .setIcon('fa-list-alt') .setName(pht('View Unified Diff')); } else { up_item .setIcon('fa-columns') .setName(pht('View Side-by-Side Diff')); } } else { up_item .setIcon('fa-refresh') .setName(pht('Load Changes')); } visible_item .setDisabled(true) .setIcon('fa-eye-slash') .setName(pht('Hide Changeset')); var diffs = JX.DOM.scry( JX.$(data.containerID), 'table', 'differential-diff'); if (diffs.length > 1) { JX.$E( 'More than one node with sigil "differential-diff" was found in "'+ data.containerID+'."'); } else if (diffs.length == 1) { visible_item.setDisabled(false); } else { // Do nothing when there is no diff shown in the table. For example, // the file is binary. } }); data.menu = menu; changeset.setViewMenu(menu); menu.open(); }, _oncollapse: function(is_collapse, e) { e.kill(); var inline = this._getInlineForEvent(e); inline.setCollapsed(is_collapse); }, _onresize: function() { this._redrawFocus(); this._redrawSelection(); - this._redrawHover(); // Force a banner redraw after a resize event. Particularly, this makes // sure the inline state updates immediately after an inline edit // operation, even if the changeset itself has not changed. this._bannerChangeset = null; this._redrawBanner(); var changesets = this._changesets; for (var ii = 0; ii < changesets.length; ii++) { changesets[ii].redrawFileTree(); } }, _onscroll: function() { this._redrawBanner(); }, _onselect: function(e) { // If the user clicked some element inside the header, like an action // icon, ignore the event. They have to click the header element itself. if (e.getTarget() !== e.getNode('differential-inline-header')) { return; } + // If the user has double-clicked or triple-clicked a header, we want to + // toggle the inline selection mode, not select text. Kill select events + // originating with this element as the target. + if (e.getType() === 'selectstart') { + e.kill(); + return; + } + var inline = this._getInlineForEvent(e); if (!inline) { return; } - // The user definitely clicked an inline, so we're going to handle the - // event. - e.kill(); + // NOTE: Don't kill or prevent the event. In particular, we want this + // click to clear any text selection as it normally would. this.selectInline(inline); }, selectInline: function(inline, force, scroll) { var selection = this._getSelectionState(); var item; if (!force) { // If the comment the user clicked is currently selected, deselect it. // This makes it easy to undo things if you clicked by mistake. if (selection.cursor !== null) { item = selection.items[selection.cursor]; if (item.target === inline) { this._setSelectionState(null, false); return; } } } // Otherwise, select the item that the user clicked. This makes it // easier to resume keyboard operations after using the mouse to do // something else. var items = selection.items; for (var ii = 0; ii < items.length; ii++) { item = items[ii]; if (item.target === inline) { this._setSelectionState(item, scroll); } } }, redrawPreview: function() { // TODO: This isn't the cleanest way to find the preview form, but // rendering no longer has direct access to it. var forms = JX.DOM.scry(document.body, 'form', 'transaction-append'); if (forms.length) { JX.DOM.invoke(forms[0], 'shouldRefresh'); } // Clear the mouse hover reticle after a substantive edit: we don't get // a "mouseout" event if the row vanished because of row being removed // after an edit. this.resetHover(); }, setFocus: function(node, extended_node) { if (!node) { var tree = this._getTreeView(); tree.setSelectedPath(null); this._selectChangeset(null); } this._focusStart = node; this._focusEnd = extended_node; this._redrawFocus(); }, _selectChangeset: function(changeset) { if (this._selectedChangeset === changeset) { return; } if (this._selectedChangeset !== null) { this._selectedChangeset.setIsSelected(false); this._selectedChangeset = null; } this._selectedChangeset = changeset; if (this._selectedChangeset !== null) { this._selectedChangeset.setIsSelected(true); } }, _redrawFocus: function() { var node = this._focusStart; var extended_node = this._focusEnd || node; var reticle = this._getFocusNode(); if (!node || this.isAsleep()) { JX.DOM.remove(reticle); return; } // Outset the reticle some pixels away from the element, so there's some // space between the focused element and the outline. var p = JX.Vector.getPos(node); var s = JX.Vector.getAggregateScrollForNode(node); var d = JX.Vector.getDim(node); p.add(s).add(d.x + 1, 4).setPos(reticle); // Compute the size we need to extend to the full extent of the focused // nodes. JX.Vector.getPos(extended_node) .add(-p.x, -p.y) .add(0, JX.Vector.getDim(extended_node).y) .add(10, -4) .setDim(reticle); JX.DOM.getContentFrame().appendChild(reticle); }, _getFocusNode: function() { if (!this._focusNode) { var node = JX.$N('div', {className : 'keyboard-focus-focus-reticle'}); this._focusNode = node; } return this._focusNode; }, _setHoverInline: function(inline) { - this._hoverInline = inline; + var origin = null; + var target = null; if (inline) { var changeset = inline.getChangeset(); var changeset_id; var side = inline.getDisplaySide(); if (side == 'right') { changeset_id = changeset.getRightChangesetID(); } else { changeset_id = changeset.getLeftChangesetID(); } var new_part; if (inline.isNewFile()) { new_part = 'N'; } else { new_part = 'O'; } var prefix = 'C' + changeset_id + new_part + 'L'; var number = inline.getLineNumber(); var length = inline.getLineLength(); try { - var origin = JX.$(prefix + number); - var target = JX.$(prefix + (number + length)); - - this._hoverOrigin = origin; - this._hoverTarget = target; + origin = JX.$(prefix + number); + target = JX.$(prefix + (number + length)); } catch (error) { // There may not be any nodes present in the document. A case where // this occurs is when you reply to a ghost inline which was made // on lines near the bottom of "long.txt" in an earlier diff, and // the file was later shortened so those lines no longer exist. For // more details, see T11662. - this._hoverOrigin = null; - this._hoverTarget = null; + origin = null; + target = null; } - } else { - this._hoverOrigin = null; - this._hoverTarget = null; } - this._redrawHover(); + this._setHoverRange(origin, target, inline); }, - _setHoverRange: function(origin, target) { - this._hoverOrigin = origin; - this._hoverTarget = target; + _setHoverRange: function(origin, target, inline) { + inline = inline || null; - this._redrawHover(); + var origin_dirty = (origin !== this._hoverOrigin); + var target_dirty = (target !== this._hoverTarget); + var inline_dirty = (inline !== this._hoverInline); + + var any_dirty = (origin_dirty || target_dirty || inline_dirty); + if (any_dirty) { + this._hoverOrigin = origin; + this._hoverTarget = target; + this._hoverInline = inline; + this._redrawHover(); + } }, resetHover: function() { - this._setHoverInline(null); - - this._hoverOrigin = null; - this._hoverTarget = null; + this._setHoverRange(null, null, null); }, _redrawHover: function() { - var reticle = this._getHoverNode(); + var map = this._hoverMap; + if (map) { + this._hoverMap = null; + this._applyHoverHighlight(map, false); + } + + var rows = this._hoverRows; + if (rows) { + this._hoverRows = null; + this._applyHoverHighlight(rows, false); + } + if (!this._hoverOrigin || this.isAsleep()) { - JX.DOM.remove(reticle); return; } - JX.DOM.getContentFrame().appendChild(reticle); - var top = this._hoverOrigin; var bot = this._hoverTarget; if (JX.$V(top).y > JX.$V(bot).y) { var tmp = top; top = bot; bot = tmp; } // Find the leftmost cell that we're going to highlight. This is the // next sibling with a "data-copy-mode" attribute, which is a marker // for the cell with actual content in it. var content_cell = top; - while (content_cell && !content_cell.getAttribute('data-copy-mode')) { + while (content_cell && !this._isContentCell(content_cell)) { content_cell = content_cell.nextSibling; } // If we didn't find a cell to highlight, don't highlight anything. if (!content_cell) { return; } - var pos = JX.$V(content_cell) - .add(JX.Vector.getAggregateScrollForNode(content_cell)); + rows = this._findContentCells(top, bot, content_cell); + + var inline = this._hoverInline; + if (!inline) { + this._hoverRows = rows; + this._applyHoverHighlight(this._hoverRows, true); + return; + } + + if (!inline.hoverMap) { + inline.hoverMap = this._newHoverMap(rows, inline); + } - var dim = JX.$V(content_cell) - .add(JX.Vector.getAggregateScrollForNode(content_cell)) - .add(-pos.x, -pos.y) - .add(JX.Vector.getDim(content_cell)); + this._hoverMap = inline.hoverMap; + this._applyHoverHighlight(this._hoverMap, true); + }, - var bpos = JX.$V(bot) - .add(JX.Vector.getAggregateScrollForNode(bot)); - dim.y = (bpos.y - pos.y) + JX.Vector.getDim(bot).y; + _applyHoverHighlight: function(items, on) { + for (var ii = 0; ii < items.length; ii++) { + var item = items[ii]; - pos.setPos(reticle); - dim.setDim(reticle); + JX.DOM.alterClass(item.lineNode, 'inline-hover', on); + JX.DOM.alterClass(item.cellNode, 'inline-hover', on); - JX.DOM.show(reticle); + if (item.bright) { + JX.DOM.alterClass(item.cellNode, 'inline-hover-bright', on); + } + + if (item.hoverNode) { + if (on) { + item.cellNode.insertBefore( + item.hoverNode, + item.cellNode.firstChild); + } else { + JX.DOM.remove(item.hoverNode); + } + } + } }, - _getHoverNode: function() { - if (!this._hoverNode) { - var attributes = { - className: 'differential-reticle' - }; - this._hoverNode = JX.$N('div', attributes); + _findContentCells: function(top, bot, content_cell) { + var head_row = JX.DOM.findAbove(top, 'tr'); + var last_row = JX.DOM.findAbove(bot, 'tr'); + + var cursor = head_row; + var rows = []; + var idx = null; + var ii; + var line_cell = null; + do { + line_cell = null; + for (ii = 0; ii < cursor.childNodes.length; ii++) { + var child = cursor.childNodes[ii]; + if (!JX.DOM.isType(child, 'td')) { + continue; + } + + if (child.getAttribute('data-n')) { + line_cell = child; + } + + if (child === content_cell) { + idx = ii; + } + + if (ii !== idx) { + continue; + } + + if (this._isContentCell(child)) { + rows.push({ + lineNode: line_cell, + cellNode: child + }); + } + + break; + } + + if (cursor === last_row) { + break; + } + + cursor = cursor.nextSibling; + } while (cursor); + + return rows; + }, + + _newHoverMap: function(rows, inline) { + var start = inline.getStartOffset(); + var end = inline.getEndOffset(); + + var info; + var content; + for (ii = 0; ii < rows.length; ii++) { + info = this._getSelectionOffset(rows[ii].cellNode, null); + + content = info.content; + content = content.replace(/\n+$/, ''); + + rows[ii].content = content; + } + + var attr_dull = { + className: 'inline-hover-text' + }; + + var attr_bright = { + className: 'inline-hover-text inline-hover-text-bright' + }; + + var attr_container = { + className: 'inline-hover-container' + }; + + var min = 0; + var max = rows.length - 1; + var offset_min; + var offset_max; + var len; + var node; + var text; + var any_highlight = false; + for (ii = 0; ii < rows.length; ii++) { + content = rows[ii].content; + len = content.length; + + if (ii === min && (start !== null)) { + offset_min = start; + } else { + offset_min = 0; + } + + if (ii === max && (end !== null)) { + offset_max = Math.min(end, len); + } else { + offset_max = len; + } + + var has_min = (offset_min > 0); + var has_max = (offset_max < len); + + if (has_min || has_max) { + any_highlight = true; + } + + rows[ii].min = offset_min; + rows[ii].max = offset_max; + rows[ii].hasMin = has_min; + rows[ii].hasMax = has_max; + } + + for (ii = 0; ii < rows.length; ii++) { + content = rows[ii].content; + offset_min = rows[ii].min; + offset_max = rows[ii].max; + + var has_highlight = (rows[ii].hasMin || rows[ii].hasMax); + + if (any_highlight) { + var parts = []; + + if (offset_min > 0) { + text = content.substring(0, offset_min); + node = JX.$N('span', attr_dull, text); + parts.push(node); + } + + if (len) { + text = content.substring(offset_min, offset_max); + node = JX.$N('span', attr_bright, text); + parts.push(node); + } + + if (offset_max < len) { + text = content.substring(offset_max, len); + node = JX.$N('span', attr_dull, text); + parts.push(node); + } + + rows[ii].hoverNode = JX.$N('div', attr_container, parts); + } else { + rows[ii].hoverNode = null; + } + + rows[ii].bright = (any_highlight && !has_highlight); } - return this._hoverNode; + return rows; }, _deleteInlineByID: function(id) { var uri = this.getInlineURI(); var data = { op: 'refdelete', id: id }; var handler = JX.bind(this, this.redrawPreview); new JX.Workflow(uri, data) .setHandler(handler) .start(); }, _getInlineForEvent: function(e) { var node = e.getNode('differential-changeset'); if (!node) { return null; } var changeset = this.getChangesetForNode(node); var inline_row = e.getNode('inline-row'); return changeset.getInlineForRow(inline_row); }, getLineNumberFromHeader: function(node) { var n = parseInt(node.getAttribute('data-n')); if (!n) { return null; } // If this is a line number that's part of a row showing more context, // we don't want to let users leave inlines here. try { JX.DOM.findAbove(node, 'tr', 'context-target'); return null; } catch (ex) { // Ignore. } return n; }, getDisplaySideFromHeader: function(th) { return (th.parentNode.firstChild != th) ? 'right' : 'left'; }, _onrangedown: function(e) { // NOTE: We're allowing "mousedown" from a touch event through so users // can leave inlines on a single line. // See PHI985. We want to exclude both right-mouse and middle-mouse // clicks from continuing. if (!e.isLeftButton()) { return; } if (this._rangeActive) { return; } var target = e.getTarget(); var number = this.getLineNumberFromHeader(target); if (!number) { return; } e.kill(); this._rangeActive = true; this._rangeOrigin = target; this._rangeTarget = target; this._setHoverRange(this._rangeOrigin, this._rangeTarget); }, _onrangemove: function(e) { if (e.getIsTouchEvent()) { return; } var is_out = (e.getType() == 'mouseout'); var target = e.getTarget(); this._updateRange(target, is_out); }, _updateRange: function(target, is_out) { // Don't update the range if this target doesn't correspond to a line // number. For instance, this may be a dead line number, like the empty // line numbers on the left hand side of a newly added file. var number = this.getLineNumberFromHeader(target); if (!number) { return; } if (this._rangeActive) { var origin = this._hoverOrigin; // Don't update the reticle if we're selecting a line range and the // "" under the cursor is on the wrong side of the file. You can // only leave inline comments on the left or right side of a file, not // across lines on both sides. var origin_side = this.getDisplaySideFromHeader(origin); var target_side = this.getDisplaySideFromHeader(target); if (origin_side != target_side) { return; } // Don't update the reticle if we're selecting a line range and the // "" under the cursor corresponds to a different file. You can // only leave inline comments on lines in a single file, not across // multiple files. var origin_table = JX.DOM.findAbove(origin, 'table'); var target_table = JX.DOM.findAbove(target, 'table'); if (origin_table != target_table) { return; } } if (is_out) { if (this._rangeActive) { // If we're dragging a range, just leave the state as it is. This // allows you to drag over something invalid while selecting a // range without the range flickering or getting lost. } else { // Otherwise, clear the current range. this.resetHover(); } return; } if (this._rangeActive) { this._rangeTarget = target; } else { this._rangeOrigin = target; this._rangeTarget = target; } this._setHoverRange(this._rangeOrigin, this._rangeTarget); }, _onrangeup: function(e) { if (!this._rangeActive) { return; } e.kill(); var origin = this._rangeOrigin; var target = this._rangeTarget; // If the user dragged a range from the bottom to the top, swap the node // order around. if (JX.$V(origin).y > JX.$V(target).y) { var tmp = target; target = origin; origin = tmp; } var node = JX.DOM.findAbove(origin, null, 'differential-changeset'); var changeset = this.getChangesetForNode(node); changeset.newInlineForRange(origin, target); this._rangeActive = false; this._rangeOrigin = null; this._rangeTarget = null; this.resetHover(); }, _redrawBanner: function() { // If the inline comment menu is open and we've done a redraw, close it. // In particular, this makes it close when you scroll the document: // otherwise, it stays open but the banner moves underneath it. if (this._dropdownMenu) { this._dropdownMenu.close(); } var node = this._getBannerNode(); var changeset = this._getVisibleChangeset(); var tree = this._getTreeView(); var formation = this.getFormationView(); if (!changeset) { this._bannerChangeset = null; JX.DOM.remove(node); tree.setFocusedPath(null); if (formation) { formation.repaint(); } return; } // Don't do anything if nothing has changed. This seems to avoid some // flickering issues in Safari, at least. if (this._bannerChangeset === changeset) { return; } this._bannerChangeset = changeset; var paths = tree.getPaths(); for (var ii = 0; ii < paths.length; ii++) { var path = paths[ii]; if (path.getChangeset() === changeset) { tree.setFocusedPath(path); } } var inlines = this._getInlinesByType(); var unsaved = inlines.unsaved; var unsubmitted = inlines.unsubmitted; var undone = inlines.undone; var done = inlines.done; var draft_done = inlines.draftDone; JX.DOM.alterClass( node, 'diff-banner-has-unsaved', !!unsaved.length); JX.DOM.alterClass( node, 'diff-banner-has-unsubmitted', !!unsubmitted.length); JX.DOM.alterClass( node, 'diff-banner-has-draft-done', !!draft_done.length); var pht = this.getTranslations(); var unsaved_button = this._getUnsavedButton(); var unsubmitted_button = this._getUnsubmittedButton(); var done_button = this._getDoneButton(); var menu_button = this._getMenuButton(); if (unsaved.length) { unsaved_button.setText(unsaved.length + ' ' + pht('Unsaved')); JX.DOM.show(unsaved_button.getNode()); } else { JX.DOM.hide(unsaved_button.getNode()); } if (unsubmitted.length || draft_done.length) { var any_draft_count = unsubmitted.length + draft_done.length; unsubmitted_button.setText(any_draft_count + ' ' + pht('Unsubmitted')); JX.DOM.show(unsubmitted_button.getNode()); } else { JX.DOM.hide(unsubmitted_button.getNode()); } if (done.length || undone.length) { // If you haven't marked any comments as "Done", we just show text // like "3 Comments". If you've marked at least one done, we show // "1 / 3 Comments". var done_text; if (done.length) { done_text = [ done.length, ' / ', (done.length + undone.length), ' ', pht('Comments') ]; } else { done_text = [ undone.length, ' ', pht('Comments') ]; } done_button.setText(done_text); JX.DOM.show(done_button.getNode()); // If any comments are not marked "Done", this cycles through the // missing comments. Otherwise, it cycles through all the saved // comments. if (undone.length) { this._doneMode = 'undone'; } else { this._doneMode = 'done'; } } else { JX.DOM.hide(done_button.getNode()); } var path_view = [icon, ' ', changeset.getDisplayPath()]; var buttons_attrs = { className: 'diff-banner-buttons' }; var buttons_list = [ unsaved_button.getNode(), unsubmitted_button.getNode(), done_button.getNode(), menu_button.getNode() ]; var buttons_view = JX.$N('div', buttons_attrs, buttons_list); var icon = new JX.PHUIXIconView() .setIcon(changeset.getIcon()) .getNode(); JX.DOM.setContent(node, [buttons_view, path_view]); document.body.appendChild(node); if (formation) { formation.repaint(); } }, _getInlinesByType: function() { var changesets = this._changesets; var unsaved = []; var unsubmitted = []; var undone = []; var done = []; var draft_done = []; var visible_done = []; var visible_collapsed = []; var visible_ghosts = []; var visible = []; var hidden = []; for (var ii = 0; ii < changesets.length; ii++) { var inlines = changesets[ii].getInlines(); var inline; var jj; for (jj = 0; jj < inlines.length; jj++) { inline = inlines[jj]; if (inline.isDeleted()) { continue; } if (inline.isSynthetic()) { continue; } if (inline.isEditing()) { unsaved.push(inline); } else if (!inline.getID()) { // These are new comments which have been cancelled, and do not // count as anything. continue; } else if (inline.isDraft()) { unsubmitted.push(inline); } else { // NOTE: Unlike other states, an inline may be marked with a // draft checkmark and still be a "done" or "undone" comment. if (inline.isDraftDone()) { draft_done.push(inline); } if (!inline.isDone()) { undone.push(inline); } else { done.push(inline); } } } for (jj = 0; jj < inlines.length; jj++) { inline = inlines[jj]; if (inline.isDeleted()) { continue; } if (inline.isEditing()) { continue; } if (inline.isHidden()) { hidden.push(inline); continue; } visible.push(inline); if (inline.isDone()) { visible_done.push(inline); } if (inline.isCollapsed()) { visible_collapsed.push(inline); } if (inline.isGhost()) { visible_ghosts.push(inline); } } } return { unsaved: unsaved, unsubmitted: unsubmitted, undone: undone, done: done, draftDone: draft_done, visibleDone: visible_done, visibleGhosts: visible_ghosts, visibleCollapsed: visible_collapsed, visible: visible, hidden: hidden }; }, _getUnsavedButton: function() { if (!this._unsavedButton) { var button = new JX.PHUIXButtonView() .setIcon('fa-commenting-o') .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE); var node = button.getNode(); var onunsaved = JX.bind(this, this._onunsavedclick); JX.DOM.listen(node, 'click', null, onunsaved); this._unsavedButton = button; } return this._unsavedButton; }, _getUnsubmittedButton: function() { if (!this._unsubmittedButton) { var button = new JX.PHUIXButtonView() .setIcon('fa-comment-o') .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE); var node = button.getNode(); var onunsubmitted = JX.bind(this, this._onunsubmittedclick); JX.DOM.listen(node, 'click', null, onunsubmitted); this._unsubmittedButton = button; } return this._unsubmittedButton; }, _getDoneButton: function() { if (!this._doneButton) { var button = new JX.PHUIXButtonView() .setIcon('fa-comment') .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE); var node = button.getNode(); var ondone = JX.bind(this, this._ondoneclick); JX.DOM.listen(node, 'click', null, ondone); this._doneButton = button; } return this._doneButton; }, _getMenuButton: function() { if (!this._menuButton) { var pht = this.getTranslations(); var button = new JX.PHUIXButtonView() .setIcon('fa-bars') .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE) .setAuralLabel(pht('Display Options')); var dropdown = new JX.PHUIXDropdownMenu(button.getNode()); this._menuItems = {}; var list = new JX.PHUIXActionListView(); dropdown.setContent(list.getNode()); var map = { hideDone: { type: 'done' }, hideCollapsed: { type: 'collapsed' }, hideGhosts: { type: 'ghosts' }, hideAll: { type: 'all' }, showAll: { type: 'show' } }; for (var k in map) { var spec = map[k]; var handler = JX.bind(this, this._onhideinlines, spec.type); var item = new JX.PHUIXActionView() .setHandler(handler); list.addItem(item); this._menuItems[k] = item; } dropdown.listen('open', JX.bind(this, this._ondropdown)); if (this.getInlineListURI()) { list.addItem( new JX.PHUIXActionView() .setDivider(true)); list.addItem( new JX.PHUIXActionView() .setIcon('fa-external-link') .setName(pht('List Inline Comments')) .setHref(this.getInlineListURI())); } this._menuButton = button; this._dropdownMenu = dropdown; } return this._menuButton; }, _ondropdown: function() { var inlines = this._getInlinesByType(); var items = this._menuItems; var pht = this.getTranslations(); items.hideDone .setName(pht('Hide "Done" Inlines')) .setDisabled(!inlines.visibleDone.length); items.hideCollapsed .setName(pht('Hide Collapsed Inlines')) .setDisabled(!inlines.visibleCollapsed.length); items.hideGhosts .setName(pht('Hide Older Inlines')) .setDisabled(!inlines.visibleGhosts.length); items.hideAll .setName(pht('Hide All Inlines')) .setDisabled(!inlines.visible.length); items.showAll .setName(pht('Show All Inlines')) .setDisabled(!inlines.hidden.length); }, _onhideinlines: function(type, e) { this._dropdownMenu.close(); e.prevent(); this._toggleInlines(type); }, _toggleInlines: function(type) { var inlines = this._getInlinesByType(); // Clear the selection state since we end up in a weird place if the // user hides the selected inline. this._setSelectionState(null); var targets; var mode = true; switch (type) { case 'done': targets = inlines.visibleDone; break; case 'collapsed': targets = inlines.visibleCollapsed; break; case 'ghosts': targets = inlines.visibleGhosts; break; case 'all': targets = inlines.visible; break; case 'show': targets = inlines.hidden; mode = false; break; } for (var ii = 0; ii < targets.length; ii++) { targets[ii].setHidden(mode); } }, _onunsavedclick: function(e) { e.kill(); var options = { filter: 'comment', wrap: true, show: true, attribute: 'unsaved' }; this._onjumpkey(1, options); }, _onunsubmittedclick: function(e) { e.kill(); var options = { filter: 'comment', wrap: true, show: true, attribute: 'anyDraft' }; this._onjumpkey(1, options); }, _ondoneclick: function(e) { e.kill(); var options = { filter: 'comment', wrap: true, show: true, attribute: this._doneMode }; this._onjumpkey(1, options); }, _getBannerNode: function() { if (!this._bannerNode) { var attributes = { className: 'diff-banner', id: 'diff-banner' }; this._bannerNode = JX.$N('div', attributes); } return this._bannerNode; }, _getVisibleChangeset: function() { if (this.isAsleep()) { return null; } if (JX.Device.getDevice() != 'desktop') { return null; } // Never show the banner if we're very near the top of the page. var margin = 480; var s = JX.Vector.getScroll(); if (s.y < margin) { return null; } // We're going to find the changeset which spans an invisible line a // little underneath the bottom of the banner. This makes the header // tick over from "A.txt" to "B.txt" just as "A.txt" scrolls completely // offscreen. var detect_height = 64; for (var ii = 0; ii < this._changesets.length; ii++) { var changeset = this._changesets[ii]; var c = changeset.getVectors(); // If the changeset starts above the line... if (c.pos.y <= (s.y + detect_height)) { // ...and ends below the line, this is the current visible changeset. if ((c.pos.y + c.dim.y) >= (s.y + detect_height)) { return changeset; } } } return null; }, _getTreeView: function() { if (!this._treeView) { var tree = new JX.DiffTreeView(); for (var ii = 0; ii < this._changesets.length; ii++) { var changeset = this._changesets[ii]; tree.addPath(changeset.getPathView()); } this._treeView = tree; } return this._treeView; }, _redrawFiletree : function() { var formation = this.getFormationView(); if (!formation) { return; } var filetree = formation.getColumn(0); var flank = filetree.getFlank(); var flank_body = flank.getBodyNode(); var tree = this._getTreeView(); JX.DOM.setContent(flank_body, tree.getNode()); }, _setupInlineCommentListeners: function() { var onsave = JX.bind(this, this._onInlineEvent, 'save'); JX.Stratcom.listen( ['submit', 'didSyntheticSubmit'], 'inline-edit-form', onsave); var oncancel = JX.bind(this, this._onInlineEvent, 'cancel'); JX.Stratcom.listen( 'click', 'inline-edit-cancel', oncancel); var onundo = JX.bind(this, this._onInlineEvent, 'undo'); JX.Stratcom.listen( 'click', 'differential-inline-comment-undo', onundo); - var onedit = JX.bind(this, this._onInlineEvent, 'edit'); - JX.Stratcom.listen( - 'click', - ['differential-inline-comment', 'differential-inline-edit'], - onedit); - var ondone = JX.bind(this, this._onInlineEvent, 'done'); JX.Stratcom.listen( 'click', ['differential-inline-comment', 'differential-inline-done'], ondone); var ondelete = JX.bind(this, this._onInlineEvent, 'delete'); JX.Stratcom.listen( 'click', ['differential-inline-comment', 'differential-inline-delete'], ondelete); - var onreply = JX.bind(this, this._onInlineEvent, 'reply'); + var onmenu = JX.bind(this, this._onInlineEvent, 'menu'); JX.Stratcom.listen( 'click', - ['differential-inline-comment', 'differential-inline-reply'], - onreply); + ['differential-inline-comment', 'inline-action-dropdown'], + onmenu); var ondraft = JX.bind(this, this._onInlineEvent, 'draft'); JX.Stratcom.listen( 'keydown', ['differential-inline-comment', 'tag:textarea'], ondraft); var on_preview_view = JX.bind(this, this._onPreviewEvent, 'view'); JX.Stratcom.listen( 'click', 'differential-inline-preview-jump', on_preview_view); }, _onPreviewEvent: function(action, e) { if (this.isAsleep()) { return; } var data = e.getNodeData('differential-inline-preview-jump'); var inline = this.getInlineByID(data.inlineCommentID); if (!inline) { return; } e.kill(); switch (action) { case 'view': this.selectInline(inline, true, true); break; } }, _onInlineEvent: function(action, e) { if (this.isAsleep()) { return; } - if (action !== 'draft') { + if (action !== 'draft' && action !== 'menu') { e.kill(); } var inline = this._getInlineForEvent(e); var is_ref = false; // If we don't have a natural inline object, the user may have clicked // an action (like "Delete") inside a preview element at the bottom of // the page. // If they did, try to find an associated normal inline to act on, and // pretend they clicked that instead. This makes the overall state of // the page more consistent. // However, there may be no normal inline (for example, because it is // on a version of the diff which is not visible). In this case, we // act by reference. if (inline === null) { var data = e.getNodeData('differential-inline-comment'); inline = this.getInlineByID(data.id); if (inline) { is_ref = true; } else { switch (action) { case 'delete': this._deleteInlineByID(data.id); return; } } } // TODO: For normal operations, highlight the inline range here. switch (action) { case 'save': inline.save(e.getTarget()); break; case 'cancel': inline.cancel(); break; case 'undo': inline.undo(); break; - case 'edit': - inline.edit(); - break; case 'done': inline.toggleDone(); break; case 'delete': inline.delete(is_ref); break; - case 'reply': - inline.reply(); - break; case 'draft': inline.triggerDraft(); break; + case 'menu': + var node = e.getNode('inline-action-dropdown'); + inline.activateMenu(node, e); + break; + } + }, + + _onSelectRange: function(e) { + this._updateSourceSelection(); + }, + + _updateSourceSelection: function() { + var ranges = this._getSelectedRanges(); + + // In Firefox, selecting multiple rows gives us multiple ranges. In + // Safari and Chrome, we get a single range. + if (!ranges.length) { + this._setSourceSelection(null, null); + return; + } + + var min = 0; + var max = ranges.length - 1; + + var head = ranges[min].startContainer; + var last = ranges[max].endContainer; + + var head_loc = this._getFragmentLocation(head); + var last_loc = this._getFragmentLocation(last); + + if (head_loc === null || last_loc === null) { + this._setSourceSelection(null, null); + return; + } + + if (head_loc.changesetID !== last_loc.changesetID) { + this._setSourceSelection(null, null); + return; + } + + head_loc.offset += ranges[min].startOffset; + last_loc.offset += ranges[max].endOffset; + + this._setSourceSelection(head_loc, last_loc); + }, + + _setSourceSelection: function(start, end) { + var start_updated = + !this._isSameSourceSelection(this._sourceSelectionStart, start); + + var end_updated = + !this._isSameSourceSelection(this._sourceSelectionEnd, end); + + if (!start_updated && !end_updated) { + return; + } + + this._sourceSelectionStart = start; + this._sourceSelectionEnd = end; + + if (!start) { + this._closeSourceSelectionMenu(); + return; + } + + var menu; + if (this._sourceSelectionMenu) { + menu = this._sourceSelectionMenu; + } else { + menu = this._newSourceSelectionMenu(); + this._sourceSelectionMenu = menu; + } + + var pos = JX.$V(start.node) + .add(0, -menu.getMenuNodeDimensions().y) + .add(0, -24); + + menu.setPosition(pos); + menu.open(); + }, + + _newSourceSelectionMenu: function() { + var pht = this.getTranslations(); + + var menu = new JX.PHUIXDropdownMenu(null) + .setWidth(240); + + // We need to disable autofocus for this menu, since it operates on the + // text selection in the document. If we leave this enabled, opening the + // menu immediately discards the selection. + menu.setDisableAutofocus(true); + + var list = new JX.PHUIXActionListView(); + menu.setContent(list.getNode()); + + var oncreate = JX.bind(this, this._onSourceSelectionMenuAction, 'create'); + + var comment_item = new JX.PHUIXActionView() + .setIcon('fa-comment-o') + .setName(pht('New Inline Comment')) + .setKeyCommand('c') + .setHandler(oncreate); + + list.addItem(comment_item); + + return menu; + }, + + _onSourceSelectionMenuAction: function(action, e) { + e.kill(); + this._closeSourceSelectionMenu(); + + switch (action) { + case 'create': + this._onKeyCreate(); + break; + } + }, + + _closeSourceSelectionMenu: function() { + if (this._sourceSelectionMenu) { + this._sourceSelectionMenu.close(); + } + }, + + _isSameSourceSelection: function(u, v) { + if (u === null && v === null) { + return true; + } + + if (u === null && v !== null) { + return false; + } + + if (u !== null && v === null) { + return false; + } + + return ( + (u.changesetID === v.changesetID) && + (u.line === v.line) && + (u.displayColumn === v.displayColumn) && + (u.offset === v.offset) + ); + }, + + _getFragmentLocation: function(fragment) { + // Find the changeset containing the fragment. + var changeset = null; + try { + var node = JX.DOM.findAbove( + fragment, + 'div', + 'differential-changeset'); + + changeset = this.getChangesetForNode(node); + if (!changeset) { + return null; + } + } catch (ex) { + return null; + } + + // Find the line number and display column for the fragment. + var line = null; + var column_count = -1; + var has_new = false; + var has_old = false; + var offset = null; + var target_node = null; + var td; + try { + + // NOTE: In Safari, you can carefully select an entire line and then + // move your mouse down slightly, causing selection of an empty + // document fragment which is an immediate child of the next "". + + // If the fragment is a direct child of a "" parent, assume the + // user has done this and select the last child of the previous row + // instead. It's possible there are other ways to do this, so this may + // not always be the right rule. + + // Otherwise, select the containing "". + + var is_end; + if (JX.DOM.isType(fragment.parentNode, 'tr')) { + // Assume this is Safari, and that the user has carefully selected a + // row and then moved their mouse down a few pixels to select the + // invisible fragment at the beginning of the next row. + var cells = fragment.parentNode.previousSibling.childNodes; + td = cells[cells.length - 1]; + is_end = true; + } else { + td = this._findContentCell(fragment); + is_end = false; + } + + var cursor = td; + while (cursor) { + if (cursor.getAttribute('data-copy-mode')) { + column_count++; + } else { + // In unified mode, the content column isn't currently marked + // with an attribute, and we can't count content columns anyway. + // Keep track of whether or not we see a "NL" (New Line) column + // and/or an "OL" (Old Line) column to try to puzzle out which + // side of the display change we're on. + + if (cursor.id.match(/NL/)) { + has_new = true; + } else if (cursor.id.match(/OL/)) { + has_old = true; + } + } + + var n = parseInt(cursor.getAttribute('data-n')); + + if (n) { + if (line === null) { + target_node = cursor; + line = n; + } + } + + cursor = cursor.previousSibling; + } + + if (!line) { + return null; + } + + if (column_count < 0) { + if (has_new || has_old) { + if (has_new) { + column_count = 1; + } else { + column_count = 0; + } + } else { + return null; + } + } + + var info = this._getSelectionOffset(td, fragment); + + if (info.found) { + offset = info.offset; + } else { + if (is_end) { + offset = info.offset; + } else { + offset = 0; + } + } + } catch (ex) { + return null; + } + + var changeset_id; + if (column_count > 0) { + changeset_id = changeset.getRightChangesetID(); + } else { + changeset_id = changeset.getLeftChangesetID(); + } + + return { + node: td, + changeset: changeset, + changesetID: changeset_id, + line: line, + displayColumn: column_count, + offset: offset, + targetNode: target_node + }; + }, + + _getSelectionOffset: function(node, target) { + // If this is an aural hint node in a unified diff, ignore it when + // calculating the selection offset. + if (node.getAttribute && node.getAttribute('data-aural')) { + return { + offset: 0, + content: '', + found: false + }; + } + + if (!node.childNodes || !node.childNodes.length) { + return { + offset: node.textContent.length, + content: node.textContent, + found: false + }; + } + + var found = false; + var offset = 0; + var content = ''; + for (var ii = 0; ii < node.childNodes.length; ii++) { + var child = node.childNodes[ii]; + + if (child === target) { + found = true; + } + + var spec = this._getSelectionOffset(child, target); + + content += spec.content; + if (!found) { + offset += spec.offset; + } + + found = found || spec.found; + } + + return { + offset: offset, + content: content, + found: found + }; + }, + + _getSelectedRanges: function() { + var ranges = []; + + if (!window.getSelection) { + return ranges; + } + + var selection = window.getSelection(); + for (var ii = 0; ii < selection.rangeCount; ii++) { + var range = selection.getRangeAt(ii); + if (range.collapsed) { + continue; + } + + ranges.push(range); + } + + return ranges; + }, + + _isContentCell: function(node) { + return !!node.getAttribute('data-copy-mode'); + }, + + _findContentCell: function(node) { + var cursor = node; + while (true) { + cursor = JX.DOM.findAbove(cursor, 'td'); + if (this._isContentCell(cursor)) { + return cursor; + } } } } }); diff --git a/webroot/rsrc/js/application/diff/DiffInline.js b/webroot/rsrc/js/application/diff/DiffInline.js index 81616c1915..cfec020544 100644 --- a/webroot/rsrc/js/application/diff/DiffInline.js +++ b/webroot/rsrc/js/application/diff/DiffInline.js @@ -1,879 +1,1042 @@ /** * @provides phabricator-diff-inline * @requires javelin-dom * @javelin */ JX.install('DiffInline', { construct : function() { }, members: { _id: null, _phid: null, _changesetID: null, _row: null, _number: null, _length: null, _displaySide: null, _isNewFile: null, _replyToCommentPHID: null, _originalText: null, _snippet: null, + _menuItems: null, + _documentEngineKey: null, _isDeleted: false, _isInvisible: false, _isLoading: false, _changeset: null, _isCollapsed: false, _isDraft: null, _isDraftDone: null, _isFixed: null, _isEditing: false, _isNew: false, _isSynthetic: false, _isHidden: false, _editRow: null, _undoRow: null, _undoType: null, _undoText: null, _draftRequest: null, _skipFocus: false, + _menu: null, + + _startOffset: null, + _endOffset: null, + _isSelected: false, bindToRow: function(row) { this._row = row; var row_data = JX.Stratcom.getData(row); row_data.inline = this; this._isCollapsed = row_data.hidden || false; // TODO: Get smarter about this once we do more editing, this is pretty // hacky. var comment = JX.DOM.find(row, 'div', 'differential-inline-comment'); var data = JX.Stratcom.getData(comment); this._readInlineState(data); this._phid = data.phid; if (data.on_right) { this._displaySide = 'right'; } else { this._displaySide = 'left'; } this._number = parseInt(data.number, 10); this._length = parseInt(data.length, 10); var original = '' + data.original; if (original.length) { this._originalText = original; } else { this._originalText = null; } this._isNewFile = data.isNewFile; this._replyToCommentPHID = data.replyToCommentPHID; this._isDraft = data.isDraft; this._isFixed = data.isFixed; this._isGhost = data.isGhost; this._isSynthetic = data.isSynthetic; this._isDraftDone = data.isDraftDone; this._changesetID = data.changesetID; this._isNew = false; this._snippet = data.snippet; + this._menuItems = data.menuItems; + this._documentEngineKey = data.documentEngineKey; + + this._startOffset = data.startOffset; + this._endOffset = data.endOffset; this._isEditing = data.isEditing; if (this._isEditing) { // NOTE: The "original" shipped down in the DOM may reflect a draft // which we're currently editing. This flow is a little clumsy, but // reasonable until some future change moves away from "send down // the inline, then immediately click edit". this.edit(null, true); } else { this.setInvisible(false); } this._startDrafts(); return this; }, isDraft: function() { return this._isDraft; }, isDone: function() { return this._isFixed; }, isEditing: function() { return this._isEditing; }, isUndo: function() { return !!this._undoRow; }, isDeleted: function() { return this._isDeleted; }, isSynthetic: function() { return this._isSynthetic; }, isDraftDone: function() { return this._isDraftDone; }, isHidden: function() { return this._isHidden; }, isGhost: function() { return this._isGhost; }, + getStartOffset: function() { + return this._startOffset; + }, + + getEndOffset: function() { + return this._endOffset; + }, + + setIsSelected: function(is_selected) { + this._isSelected = is_selected; + + if (this._row) { + JX.DOM.alterClass( + this._row, + 'inline-comment-selected', + this._isSelected); + } + + return this; + }, + bindToRange: function(data) { this._displaySide = data.displaySide; this._number = parseInt(data.number, 10); this._length = parseInt(data.length, 10); this._isNewFile = data.isNewFile; this._changesetID = data.changesetID; this._isNew = true; + if (data.hasOwnProperty('startOffset')) { + this._startOffset = data.startOffset; + } else { + this._startOffset = null; + } + + if (data.hasOwnProperty('endOffset')) { + this._endOffset = data.endOffset; + } else { + this._endOffset = null; + } + // Insert the comment after any other comments which already appear on // the same row. var parent_row = JX.DOM.findAbove(data.target, 'tr'); var target_row = parent_row.nextSibling; while (target_row && JX.Stratcom.hasSigil(target_row, 'inline-row')) { target_row = target_row.nextSibling; } var row = this._newRow(); parent_row.parentNode.insertBefore(row, target_row); this.setInvisible(true); this._startDrafts(); return this; }, bindToReply: function(inline) { this._displaySide = inline._displaySide; this._number = inline._number; this._length = inline._length; this._isNewFile = inline._isNewFile; this._changesetID = inline._changesetID; this._isNew = true; + this._documentEngineKey = inline._documentEngineKey; this._replyToCommentPHID = inline._phid; var changeset = this.getChangeset(); // We're going to figure out where in the document to position the new // inline. Normally, it goes after any existing inline rows (so if // several inlines reply to the same line, they appear in chronological // order). // However: if inlines are threaded, we want to put the new inline in // the right place in the thread. This might be somewhere in the middle, // so we need to do a bit more work to figure it out. // To find the right place in the thread, we're going to look for any // inline which is at or above the level of the comment we're replying // to. This means we've reached a new fork of the thread, and should // put our new inline before the comment we found. var ancestor_map = {}; var ancestor = inline; var reply_phid; while (ancestor) { reply_phid = ancestor.getReplyToCommentPHID(); if (!reply_phid) { break; } ancestor_map[reply_phid] = true; ancestor = changeset.getInlineByPHID(reply_phid); } var parent_row = inline._row; var target_row = parent_row.nextSibling; while (target_row && JX.Stratcom.hasSigil(target_row, 'inline-row')) { var target = changeset.getInlineForRow(target_row); reply_phid = target.getReplyToCommentPHID(); // If we found an inline which is replying directly to some ancestor // of this new comment, this is where the new rows go. if (ancestor_map.hasOwnProperty(reply_phid)) { break; } target_row = target_row.nextSibling; } var row = this._newRow(); parent_row.parentNode.insertBefore(row, target_row); this.setInvisible(true); this._startDrafts(); return this; }, setChangeset: function(changeset) { this._changeset = changeset; return this; }, getChangeset: function() { return this._changeset; }, setEditing: function(editing) { this._isEditing = editing; return this; }, setHidden: function(hidden) { this._isHidden = hidden; this._redraw(); return this; }, canReply: function() { - if (!this._hasAction('reply')) { - return false; - } - - return true; + return this._hasMenuAction('reply'); }, canEdit: function() { - if (!this._hasAction('edit')) { - return false; - } - - return true; + return this._hasMenuAction('edit'); }, canDone: function() { if (!JX.DOM.scry(this._row, 'input', 'differential-inline-done').length) { return false; } return true; }, canCollapse: function() { - if (!JX.DOM.scry(this._row, 'a', 'hide-inline').length) { - return false; - } - - return true; + return this._hasMenuAction('collapse'); }, getRawText: function() { return this._originalText; }, - _hasAction: function(action) { - var nodes = JX.DOM.scry(this._row, 'a', 'differential-inline-' + action); - return (nodes.length > 0); - }, - _newRow: function() { var attributes = { sigil: 'inline-row' }; var row = JX.$N('tr', attributes); JX.Stratcom.getData(row).inline = this; this._row = row; this._id = null; this._phid = null; this._isCollapsed = false; this._originalText = null; return row; }, setCollapsed: function(collapsed) { + this._closeMenu(); + this._isCollapsed = collapsed; var op; if (collapsed) { op = 'hide'; } else { op = 'show'; } var inline_uri = this._getInlineURI(); var comment_id = this._id; new JX.Workflow(inline_uri, {op: op, ids: comment_id}) .setHandler(JX.bag) .start(); this._redraw(); this._didUpdate(true); }, isCollapsed: function() { return this._isCollapsed; }, toggleDone: function() { var uri = this._getInlineURI(); var data = { op: 'done', id: this._id }; var ondone = JX.bind(this, this._ondone); new JX.Workflow(uri, data) .setHandler(ondone) .start(); }, _ondone: function(response) { var checkbox = JX.DOM.find( this._row, 'input', 'differential-inline-done'); checkbox.checked = (response.isChecked ? 'checked' : null); var comment = JX.DOM.findAbove( checkbox, 'div', 'differential-inline-comment'); JX.DOM.alterClass(comment, 'inline-is-done', response.isChecked); // NOTE: This is marking the inline as having an unsubmitted checkmark, // as opposed to a submitted checkmark. This is different from the // top-level "draft" state of unsubmitted comments. JX.DOM.alterClass(comment, 'inline-state-is-draft', response.draftState); this._isFixed = response.isChecked; this._isDraftDone = !!response.draftState; this._didUpdate(); }, create: function(text) { + var changeset = this.getChangeset(); + if (!this._documentEngineKey) { + this._documentEngineKey = changeset.getResponseDocumentEngineKey(); + } + var uri = this._getInlineURI(); var handler = JX.bind(this, this._oncreateresponse); var data = this._newRequestData('new', text); this.setLoading(true); new JX.Request(uri, handler) .setData(data) .send(); }, - reply: function(text) { + reply: function(with_quote) { + this._closeMenu(); + + var text; + if (with_quote) { + text = this.getRawText(); + text = '> ' + text.replace(/\n/g, '\n> ') + '\n\n'; + } else { + text = ''; + } + var changeset = this.getChangeset(); return changeset.newInlineReply(this, text); }, edit: function(text, skip_focus) { + this._closeMenu(); + this._skipFocus = !!skip_focus; // If you edit an inline ("A"), modify the text ("AB"), cancel, and then // edit it again: discard the undo state ("AB"). Otherwise we end up // with an open editor and an active "Undo" link, which is weird. if (this._undoRow) { JX.DOM.remove(this._undoRow); this._undoRow = null; this._undoType = null; this._undoText = null; } var uri = this._getInlineURI(); var handler = JX.bind(this, this._oneditresponse); var data = this._newRequestData('edit', text || null); this.setLoading(true); new JX.Request(uri, handler) .setData(data) .send(); }, delete: function(is_ref) { var uri = this._getInlineURI(); var handler = JX.bind(this, this._ondeleteresponse); // NOTE: This may be a direct delete (the user clicked on the inline // itself) or a "refdelete" (the user clicked somewhere else, like the // preview, but the inline is present on the page). // For a "refdelete", we prompt the user to confirm that they want to // delete the comment, because they can not undo deletions from the // preview. We could jump the user to the inline instead, but this would // be somewhat disruptive and make deleting several comments more // difficult. var op; if (is_ref) { op = 'refdelete'; } else { op = 'delete'; } var data = this._newRequestData(op); this.setLoading(true); new JX.Workflow(uri, data) .setHandler(handler) .start(); }, getDisplaySide: function() { return this._displaySide; }, getLineNumber: function() { return this._number; }, getLineLength: function() { return this._length; }, isNewFile: function() { return this._isNewFile; }, getID: function() { return this._id; }, getPHID: function() { return this._phid; }, getChangesetID: function() { return this._changesetID; }, getReplyToCommentPHID: function() { return this._replyToCommentPHID; }, setDeleted: function(deleted) { this._isDeleted = deleted; this._redraw(); return this; }, setInvisible: function(invisible) { this._isInvisible = invisible; this._redraw(); return this; }, setLoading: function(loading) { this._isLoading = loading; this._redraw(); return this; }, _newRequestData: function(operation, text) { - return { + var data = { op: operation, - id: this._id, + is_new: this.isNewFile(), on_right: ((this.getDisplaySide() == 'right') ? 1 : 0), renderer: this.getChangeset().getRendererKey(), - number: this.getLineNumber(), - length: this.getLineLength(), - is_new: this.isNewFile(), - changesetID: this.getChangesetID(), - replyToCommentPHID: this.getReplyToCommentPHID() || '', - text: text || '' + text: text || null }; + + if (operation === 'new') { + var create_data = { + changesetID: this.getChangesetID(), + documentEngineKey: this._documentEngineKey, + replyToCommentPHID: this.getReplyToCommentPHID(), + startOffset: this._startOffset, + endOffset: this._endOffset, + number: this.getLineNumber(), + length: this.getLineLength() + }; + + JX.copy(data, create_data); + } else { + var edit_data = { + id: this._id + }; + + JX.copy(data, edit_data); + } + + return data; }, _oneditresponse: function(response) { var rows = JX.$H(response.view).getNode(); this._readInlineState(response.inline); this._drawEditRows(rows); this.setLoading(false); this.setInvisible(true); }, _oncreateresponse: function(response) { var rows = JX.$H(response.view).getNode(); this._readInlineState(response.inline); this._drawEditRows(rows); }, _readInlineState: function(state) { this._id = state.id; }, _ondeleteresponse: function() { // If there's an existing "unedit" undo element, remove it. if (this._undoRow) { JX.DOM.remove(this._undoRow); this._undoRow = null; } // If there's an existing editor, remove it. This happens when you // delete a comment from the comment preview area. In this case, we // read and preserve the text so "Undo" restores it. var text; if (this._editRow) { text = this._readText(this._editRow); JX.DOM.remove(this._editRow); this._editRow = null; } this._drawUndeleteRows(text); this.setLoading(false); this.setDeleted(true); this._didUpdate(); }, _drawUndeleteRows: function(text) { this._undoType = 'undelete'; this._undoText = text || null; return this._drawUndoRows('undelete', this._row); }, _drawUneditRows: function(text) { this._undoType = 'unedit'; this._undoText = text; return this._drawUndoRows('unedit', null, text); }, _drawUndoRows: function(mode, cursor, text) { var templates = this.getChangeset().getUndoTemplates(); var template; if (this.getDisplaySide() == 'right') { template = templates.r; } else { template = templates.l; } template = JX.$H(template).getNode(); this._undoRow = this._drawRows(template, cursor, mode, text); }, _drawContentRows: function(rows) { return this._drawRows(rows, null, 'content'); }, _drawEditRows: function(rows) { this.setEditing(true); this._editRow = this._drawRows(rows, null, 'edit'); }, _drawRows: function(rows, cursor, type, text) { var first_row = JX.DOM.scry(rows, 'tr')[0]; var row = first_row; var anchor = cursor || this._row; cursor = cursor || this._row.nextSibling; var result_row; var next_row; while (row) { // Grab this first, since it's going to change once we insert the row // into the document. next_row = row.nextSibling; // Bind edit and undo rows to this DiffInline object so that // interactions like hovering work properly. JX.Stratcom.getData(row).inline = this; anchor.parentNode.insertBefore(row, cursor); cursor = row; if (!result_row) { result_row = row; } if (!this._skipFocus) { // If the row has a textarea, focus it. This allows the user to start // typing a comment immediately after a "new", "edit", or "reply" // action. // (When simulating an "edit" on page load, we don't do this.) var textareas = JX.DOM.scry( row, 'textarea', 'differential-inline-comment-edit-textarea'); if (textareas.length) { var area = textareas[0]; area.focus(); var length = area.value.length; JX.TextAreaUtils.setSelectionRange(area, length, length); } } row = next_row; } JX.Stratcom.invoke('resize'); return result_row; }, save: function(form) { var handler = JX.bind(this, this._onsubmitresponse); this.setLoading(true); JX.Workflow.newFromForm(form) .setHandler(handler) .start(); }, undo: function() { JX.DOM.remove(this._undoRow); this._undoRow = null; if (this._undoType === 'undelete') { var uri = this._getInlineURI(); var data = this._newRequestData('undelete'); var handler = JX.bind(this, this._onundelete); this.setDeleted(false); this.setLoading(true); new JX.Request(uri, handler) .setData(data) .send(); } if (this._undoText !== null) { this.edit(this._undoText); } }, _onundelete: function() { this.setLoading(false); this._didUpdate(); }, cancel: function() { var text = this._readText(this._editRow); JX.DOM.remove(this._editRow); this._editRow = null; if (text && text.length && (text != this._originalText)) { this._drawUneditRows(text); } - this.setEditing(false); - // If this was an empty box and we typed some text and then hit cancel, // don't show the empty concrete inline. if (!this._originalText) { this.setInvisible(true); } else { this.setInvisible(false); } // If you "undo" to restore text ("AB") and then "Cancel", we put you // back in the original text state ("A"). We also send the original // text ("A") to the server as the current persistent state. var uri = this._getInlineURI(); var data = this._newRequestData('cancel', this._originalText); var handler = JX.bind(this, this._onCancelResponse); this.setLoading(true); new JX.Request(uri, handler) .setData(data) .send(); this._didUpdate(true); }, _onCancelResponse: function(response) { + this.setEditing(false); this.setLoading(false); // If the comment was empty when we started editing it (there's no // original text) and empty when we finished editing it (there's no // undo row), just delete the comment. if (!this._originalText && !this.isUndo()) { this.setDeleted(true); JX.DOM.remove(this._row); this._row = null; this._didUpdate(); } }, _readText: function(row) { var textarea; try { textarea = JX.DOM.find( row, 'textarea', 'differential-inline-comment-edit-textarea'); } catch (ex) { return null; } return textarea.value; }, _onsubmitresponse: function(response) { if (this._editRow) { JX.DOM.remove(this._editRow); this._editRow = null; } this.setLoading(false); this.setInvisible(false); this.setEditing(false); this._onupdate(response); }, _onupdate: function(response) { var new_row; if (response.view) { new_row = this._drawContentRows(JX.$H(response.view).getNode()); } // TODO: Save the old row so the action it's undo-able if it was a // delete. var remove_old = true; if (remove_old) { JX.DOM.remove(this._row); } // If you delete the content on a comment and save it, it acts like a // delete: the server does not return a new row. if (new_row) { this.bindToRow(new_row); } else { this.setDeleted(true); this._row = null; } this._didUpdate(); }, _didUpdate: function(local_only) { // After making changes to inline comments, refresh the transaction // preview at the bottom of the page. if (!local_only) { this.getChangeset().getChangesetList().redrawPreview(); } this.getChangeset().getChangesetList().redrawCursor(); this.getChangeset().getChangesetList().resetHover(); // Emit a resize event so that UI elements like the keyboard focus // reticle can redraw properly. JX.Stratcom.invoke('resize'); }, _redraw: function() { var is_invisible = (this._isInvisible || this._isDeleted || this._isHidden); var is_loading = this._isLoading; var is_collapsed = (this._isCollapsed && !this._isHidden); var row = this._row; JX.DOM.alterClass(row, 'differential-inline-hidden', is_invisible); JX.DOM.alterClass(row, 'differential-inline-loading', is_loading); JX.DOM.alterClass(row, 'inline-hidden', is_collapsed); }, _getInlineURI: function() { var changeset = this.getChangeset(); var list = changeset.getChangesetList(); return list.getInlineURI(); }, _startDrafts: function() { if (this._draftRequest) { return; } var onresponse = JX.bind(this, this._onDraftResponse); var draft = JX.bind(this, this._getDraftState); var uri = this._getInlineURI(); var request = new JX.PhabricatorShapedRequest(uri, onresponse, draft); // The main transaction code uses a 500ms delay on desktop and a // 10s delay on mobile. Perhaps this should be standardized. request.setRateLimit(2000); this._draftRequest = request; request.start(); }, _onDraftResponse: function() { // For now, do nothing. }, _getDraftState: function() { if (this.isDeleted()) { return null; } if (!this.isEditing()) { return null; } var text = this._readText(this._editRow); if (text === null) { return null; } return { op: 'draft', id: this.getID(), text: text }; }, triggerDraft: function() { if (this._draftRequest) { this._draftRequest.trigger(); } + }, + + activateMenu: function(button, e) { + // If we already have a menu for this button, let the menu handle the + // event. + var data = JX.Stratcom.getData(button); + if (data.menu) { + return; + } + + e.prevent(); + + var menu = new JX.PHUIXDropdownMenu(button) + .setWidth(240); + + var list = new JX.PHUIXActionListView(); + var items = this._newMenuItems(menu); + for (var ii = 0; ii < items.length; ii++) { + list.addItem(items[ii]); + } + + menu.setContent(list.getNode()); + + data.menu = menu; + this._menu = menu; + + menu.listen('open', JX.bind(this, function() { + var changeset_list = this.getChangeset().getChangesetList(); + changeset_list.selectInline(this, true); + })); + + menu.open(); + }, + + _newMenuItems: function(menu) { + var items = []; + + for (var ii = 0; ii < this._menuItems.length; ii++) { + var spec = this._menuItems[ii]; + + var onmenu = JX.bind(this, this._onMenuItem, menu, spec.action, spec); + + var item = new JX.PHUIXActionView() + .setIcon(spec.icon) + .setName(spec.label) + .setHandler(onmenu); + + if (spec.key) { + item.setKeyCommand(spec.key); + } + + items.push(item); + } + + return items; + }, + + _onMenuItem: function(menu, action, spec, e) { + e.prevent(); + menu.close(); + + switch (action) { + case 'reply': + this.reply(); + break; + case 'quote': + this.reply(true); + break; + case 'collapse': + this.setCollapsed(true); + break; + case 'delete': + this.delete(); + break; + case 'edit': + this.edit(); + break; + case 'raw': + new JX.Workflow(spec.uri) + .start(); + break; + } + + }, + + _hasMenuAction: function(action) { + for (var ii = 0; ii < this._menuItems.length; ii++) { + var spec = this._menuItems[ii]; + if (spec.action === action) { + return true; + } + } + return false; + }, + + _closeMenu: function() { + if (this._menu) { + this._menu.close(); + } } } }); diff --git a/webroot/rsrc/js/core/behavior-oncopy.js b/webroot/rsrc/js/core/behavior-oncopy.js index b56e83ab32..8c2aa7808f 100644 --- a/webroot/rsrc/js/core/behavior-oncopy.js +++ b/webroot/rsrc/js/core/behavior-oncopy.js @@ -1,322 +1,333 @@ /** * @provides javelin-behavior-phabricator-oncopy * @requires javelin-behavior * javelin-dom */ JX.behavior('phabricator-oncopy', function() { var copy_root; var copy_mode; function onstartselect(e) { var target = e.getTarget(); + // See T13513. If the user selects multiple lines in a 2-up diff and then + // clicks "New Inline Comment" in the context menu that pops up, the + // mousedown causes us to arrive here and remove the "selectable" CSS + // styles, and creates a flash of selected content across both sides of + // the diff, which is distracting. To attempt to avoid this, bail out if + // the user clicked a link. + + if (JX.DOM.isType(target, 'a')) { + return; + } + var container; try { // NOTE: For now, all elements with custom oncopy behavior are tables, // so this tag selection will hit everything we need it to. container = JX.DOM.findAbove(target, 'table', 'intercept-copy'); } catch (ex) { container = null; } var old_mode = copy_mode; clear_selection_mode(); if (!container) { return; } // If the potential selection is starting inside an inline comment, // don't do anything special. try { if (JX.DOM.findAbove(target, 'div', 'differential-inline-comment')) { return; } } catch (ex) { // Continue. } // Find the row and cell we're copying from. If we don't find anything, // don't do anything special. var row; var cell; try { // The target may be the cell we're after, particularly if you click // in the white area to the right of the text, towards the end of a line. if (JX.DOM.isType(target, 'td')) { cell = target; } else { cell = JX.DOM.findAbove(target, 'td'); } row = JX.DOM.findAbove(target, 'tr'); } catch (ex) { return; } // If the row doesn't have enough nodes, bail out. Note that it's okay // to begin a selection in the whitespace on the opposite side of an inline // comment. For example, if there's an inline comment on the right side of // a diff, it's okay to start selecting the left side of the diff by // clicking the corresponding empty space on the left side. if (row.childNodes.length < 4) { return; } // If the selection's cell is in the "old" diff or the "new" diff, we'll // activate an appropriate copy mode. var mode; if (cell === row.childNodes[1]) { mode = 'copy-l'; } else if ((row.childNodes.length >= 4) && (cell === row.childNodes[4])) { mode = 'copy-r'; } else { return; } // We found a copy mode, so set it as the current active mode. copy_root = container; copy_mode = mode; // If the user makes a selection, then clicks again inside the same // selection, browsers retain the selection. This is because the user may // want to drag-and-drop the text to another window. // Handle special cases when the click is inside an existing selection. var ranges = get_selected_ranges(); if (ranges.length) { // We'll have an existing selection if the user selects text on the right // side of a diff, then clicks the selection on the left side of the // diff, even if the second click is clicking part of the selection // range where the selection highlight is currently invisible because // of CSS rules. // This behavior looks and feels glitchy: an invisible selection range // suddenly pops into existence and there's a bunch of flicker. If we're // switching selection modes, clear the old selection to avoid this: // assume the user is not trying to drag-and-drop text which is not // visually selected. if (old_mode !== copy_mode) { window.getSelection().removeAllRanges(); } // In the more mundane case, if the user selects some text on one side // of a diff and then clicks that same selection in a normal way (in // the visible part of the highlighted text), we may either be altering // the selection range or may be initiating a text drag depending on how // long they hold the button for. Regardless of what we're doing, we're // still in a selection mode, so keep the visual hints active. JX.DOM.alterClass(copy_root, copy_mode, true); } // We've chosen a mode and saved it now, but we don't actually update to // apply any visual changes until the user actually starts making some // kind of selection. } // When the selection range changes, apply CSS classes if the selection is // nonempty. We don't want to make visual changes to the document immediately - // when the user press the mouse button, since we aren't yet sure that + // when the user presses the mouse button, since we aren't yet sure that // they are starting a selection: instead, wait for them to actually select // something. function onchangeselect() { if (!copy_mode) { return; } var ranges = get_selected_ranges(); JX.DOM.alterClass(copy_root, copy_mode, !!ranges.length); } // When the user releases the mouse, get rid of the selection mode if we // don't have a selection. function onendselect(e) { if (!copy_mode) { return; } var ranges = get_selected_ranges(); if (!ranges.length) { clear_selection_mode(); } } function get_selected_ranges() { var ranges = []; if (!window.getSelection) { return ranges; } var selection = window.getSelection(); for (var ii = 0; ii < selection.rangeCount; ii++) { var range = selection.getRangeAt(ii); if (range.collapsed) { continue; } ranges.push(range); } return ranges; } function clear_selection_mode() { if (!copy_root) { return; } JX.DOM.alterClass(copy_root, copy_mode, false); copy_root = null; copy_mode = null; } function oncopy(e) { // If we aren't in a special copy mode, just fall back to default // behavior. if (!copy_mode) { return; } var ranges = get_selected_ranges(); if (!ranges.length) { return; } var text = []; for (var ii = 0; ii < ranges.length; ii++) { var range = ranges[ii]; var fragment = range.cloneContents(); if (!fragment.childNodes.length) { continue; } // In Chrome and Firefox, because we've already applied "user-select" // CSS to everything we don't intend to copy, the text in the selection // range is correct, and the range will include only the correct text // nodes. // However, in Safari, "user-select" does not apply to clipboard // operations, so we get everything in the document between the beginning // and end of the selection, even if it isn't visibly selected. // Even in Chrome and Firefox, we can get partial empty nodes: for // example, where a "" is selectable but no content in the node is // selectable. (We have to leave the "" itself selectable because // of how Firefox applies "user-select" rules.) // The nodes we get here can also start and end more or less anywhere. // One saving grace is that we use "content: attr(data-n);" to render // the line numbers and no browsers copy this content, so we don't have // to worry about figuring out when text is line numbers. for (var jj = 0; jj < fragment.childNodes.length; jj++) { var node = fragment.childNodes[jj]; text.push(extract_text(node)); } } text = flatten_list(text); text = text.join(''); var rawEvent = e.getRawEvent(); var data; if ('clipboardData' in rawEvent) { data = rawEvent.clipboardData; } else { data = window.clipboardData; } data.setData('Text', text); e.prevent(); } function extract_text(node) { var ii; var text = []; if (JX.DOM.isType(node, 'tr')) { // This is an inline comment row, so we never want to copy any // content inside of it. if (JX.Stratcom.hasSigil(node, 'inline-row')) { return null; } // This is a "Show More Context" row, so we never want to copy any // of the content inside. if (JX.Stratcom.hasSigil(node, 'context-target')) { return null; } // Assume anything else is a source code row. Keep only "" cells // with the correct mode. for (ii = 0; ii < node.childNodes.length; ii++) { text.push(extract_text(node.childNodes[ii])); } return text; } if (JX.DOM.isType(node, 'td')) { var node_mode = node.getAttribute('data-copy-mode'); if (node_mode !== copy_mode) { return; } // Otherwise, fall through and extract this node's text normally. } if (node.getAttribute) { var copy_text = node.getAttribute('data-copy-text'); if (copy_text) { return copy_text; } } if (!node.childNodes || !node.childNodes.length) { return node.textContent; } for (ii = 0; ii < node.childNodes.length; ii++) { var child = node.childNodes[ii]; text.push(extract_text(child)); } return text; } function flatten_list(list) { var stack = [list]; var result = []; while (stack.length) { var next = stack.pop(); if (JX.isArray(next)) { for (var ii = 0; ii < next.length; ii++) { stack.push(next[ii]); } } else if (next === null) { continue; } else if (next === undefined) { continue; } else { result.push(next); } } return result.reverse(); } JX.enableDispatch(document.body, 'copy'); JX.enableDispatch(window, 'selectionchange'); JX.Stratcom.listen('mousedown', null, onstartselect); JX.Stratcom.listen('selectionchange', null, onchangeselect); JX.Stratcom.listen('mouseup', null, onendselect); JX.Stratcom.listen('copy', null, oncopy); }); diff --git a/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js b/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js index b49cb5b9f0..a9fa6c8e00 100644 --- a/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js +++ b/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js @@ -1,264 +1,312 @@ /** * @provides phuix-dropdown-menu * @requires javelin-install * javelin-util * javelin-dom * javelin-vector * javelin-stratcom * @javelin */ /** * Basic interaction for a dropdown menu. * * The menu is unaware of the content inside it, so it can not close itself * when an item is selected. Callers must make a call to @{method:close} after * an item is chosen in order to close the menu. */ JX.install('PHUIXDropdownMenu', { construct : function(node) { this._node = node; - JX.DOM.listen( - this._node, - 'click', - null, - JX.bind(this, this._onclick)); + if (node) { + JX.DOM.listen( + this._node, + 'click', + null, + JX.bind(this, this._onclick)); + } JX.Stratcom.listen( 'mousedown', null, JX.bind(this, this._onanyclick)); JX.Stratcom.listen( 'resize', null, JX.bind(this, this._adjustposition)); JX.Stratcom.listen('phuix.dropdown.open', null, JX.bind(this, this.close)); JX.Stratcom.listen('keydown', null, JX.bind(this, this._onkey)); JX.DOM.listen( this._getMenuNode(), 'click', 'tag:a', JX.bind(this, this._onlink)); }, events: ['open', 'close'], properties: { width: null, align: 'right', offsetX: 0, - offsetY: 0 + offsetY: 0, + disableAutofocus: false }, members: { _node: null, _menu: null, _open: false, _content: null, + _position: null, + _visible: false, setContent: function(content) { JX.DOM.setContent(this._getMenuNode(), content); return this; }, open: function() { if (this._open) { return; } this.invoke('open'); JX.Stratcom.invoke('phuix.dropdown.open'); this._open = true; this._show(); return this; }, close: function() { if (!this._open) { return; } this._open = false; this._hide(); this.invoke('close'); return this; }, + setPosition: function(pos) { + this._position = pos; + this._setMenuNodePosition(pos); + return this; + }, + _getMenuNode: function() { if (!this._menu) { var attrs = { className: 'phuix-dropdown-menu', role: 'button' }; var menu = JX.$N('div', attrs); this._menu = menu; } return this._menu; }, _onclick : function(e) { if (this._open) { this.close(); } else { this.open(); } e.prevent(); }, _onlink: function(e) { if (!e.isNormalClick()) { return; } // If this action was built dynamically with PHUIXActionView, don't // do anything by default. The caller is responsible for installing a // handler if they want to react to clicks. if (e.getNode('phuix-action-view')) { return; } // If this item opens a submenu, we don't want to close the current // menu. One submenu is "Edit Related Objects..." on mobile. var link = e.getNode('tag:a'); if (JX.Stratcom.hasSigil(link, 'keep-open')) { return; } this.close(); }, _onanyclick : function(e) { if (!this._open) { return; } if (JX.Stratcom.pass(e)) { return; } var t = e.getTarget(); while (t) { if (t == this._menu || t == this._node) { return; } t = t.parentNode; } this.close(); }, _show : function() { - document.body.appendChild(this._menu); + if (!this._visible) { + this._visible = true; + document.body.appendChild(this._menu); + } if (this.getWidth()) { new JX.Vector(this.getWidth(), null).setDim(this._menu); } this._adjustposition(); - JX.DOM.alterClass(this._node, 'phuix-dropdown-open', true); - - this._node.setAttribute('aria-expanded', 'true'); + if (this._node) { + JX.DOM.alterClass(this._node, 'phuix-dropdown-open', true); + this._node.setAttribute('aria-expanded', 'true'); + } // Try to highlight the first link in the menu for assistive technologies. - var links = JX.DOM.scry(this._menu, 'a'); - if (links[0]) { - JX.DOM.focus(links[0]); + if (!this.getDisableAutofocus()) { + var links = JX.DOM.scry(this._menu, 'a'); + if (links[0]) { + JX.DOM.focus(links[0]); + } } }, _hide : function() { + this._visible = false; JX.DOM.remove(this._menu); - JX.DOM.alterClass(this._node, 'phuix-dropdown-open', false); - - this._node.setAttribute('aria-expanded', 'false'); + if (this._node) { + JX.DOM.alterClass(this._node, 'phuix-dropdown-open', false); + this._node.setAttribute('aria-expanded', 'false'); + } }, _adjustposition : function() { if (!this._open) { return; } + if (this._position) { + this._setMenuNodePosition(this._position); + return; + } + + if (!this._node) { + return; + } + var m = JX.Vector.getDim(this._menu); var v = JX.$V(this._node); var d = JX.Vector.getDim(this._node); var alignments = ['right', 'left']; var disallow = {}; var margin = 8; // If "right" alignment would leave us with the dropdown near or off the // left side of the screen, disallow it. var x_min = ((v.x + d.x) - m.x); if (x_min < margin) { disallow.right = true; } var align = this.getAlign(); // If the position disallows the configured alignment, try the next // best alignment instead. // If no alignment is allowed, we'll stick with the original alignment // and accept that it isn't going to render very nicely. This can happen // if the browser window is very, very small. if (align in disallow) { for (var ii = 0; ii < alignments.length; ii++) { if (!(alignments[ii] in disallow)) { align = alignments[ii]; break; } } } switch (align) { case 'right': v = v.add(d) .add(JX.$V(-m.x, 0)); break; default: v = v.add(0, d.y); break; } - v = v.add(this.getOffsetX(), this.getOffsetY()); + this._setMenuNodePosition(v); + }, + _setMenuNodePosition: function(v) { + v = v.add(this.getOffsetX(), this.getOffsetY()); v.setPos(this._menu); }, + getMenuNodeDimensions: function() { + if (!this._visible) { + document.body.appendChild(this._menu); + } + + var dim = JX.Vector.getDim(this._menu); + + if (!this._visible) { + JX.DOM.remove(this._menu); + } + + return dim; + }, + _onkey: function(e) { // When the user presses escape with a menu open, close the menu and // refocus the button which activates the menu. In particular, this makes // popups more usable with assistive technologies. if (!this._open) { return; } if (e.getSpecialKey() != 'esc') { return; } this.close(); - JX.DOM.focus(this._node); + + if (this._node) { + JX.DOM.focus(this._node); + } e.prevent(); } } });