Path: blob/master/webroot/rsrc/js/application/diff/DiffChangeset.js
12242 views
/**1* @provides phabricator-diff-changeset2* @requires javelin-dom3* javelin-util4* javelin-stratcom5* javelin-install6* javelin-workflow7* javelin-router8* javelin-behavior-device9* javelin-vector10* phabricator-diff-inline11* phabricator-diff-path-view12* phuix-button-view13* javelin-external-editor-link-engine14* @javelin15*/1617JX.install('DiffChangeset', {1819construct : function(node) {20this._node = node;2122var data = this._getNodeData();2324this._renderURI = data.renderURI;25this._ref = data.ref;26this._loaded = data.loaded;27this._treeNodeID = data.treeNodeID;2829this._leftID = data.left;30this._rightID = data.right;3132this._displayPath = JX.$H(data.displayPath);33this._pathParts = data.pathParts;34this._icon = data.icon;3536this._editorURITemplate = data.editorURITemplate;37this._editorConfigureURI = data.editorConfigureURI;38this._showPathURI = data.showPathURI;39this._showDirectoryURI = data.showDirectoryURI;4041this._pathIconIcon = data.pathIconIcon;42this._pathIconColor = data.pathIconColor;43this._isLowImportance = data.isLowImportance;44this._isOwned = data.isOwned;45this._isLoading = true;4647this._inlines = null;4849if (data.changesetState) {50this._loadChangesetState(data.changesetState);51}5253JX.enableDispatch(window, 'selectstart');5455var onselect = JX.bind(this, this._onClickHeader);56JX.DOM.listen(57this._node,58['mousedown', 'selectstart'],59'changeset-header',60onselect);61},6263members: {64_node: null,65_loaded: false,66_sequence: 0,67_stabilize: false,6869_renderURI: null,70_ref: null,71_rendererKey: null,72_highlight: null,73_requestDocumentEngineKey: null,74_responseDocumentEngineKey: null,75_availableDocumentEngineKeys: null,76_characterEncoding: null,77_undoTemplates: null,7879_leftID: null,80_rightID: null,8182_inlines: null,83_visible: true,8485_displayPath: null,8687_changesetList: null,88_icon: null,8990_editorURITemplate: null,91_editorConfigureURI: null,92_showPathURI: null,93_showDirectoryURI: null,9495_pathView: null,9697_pathIconIcon: null,98_pathIconColor: null,99_isLowImportance: null,100_isOwned: null,101_isHidden: null,102_isSelected: false,103_viewMenu: null,104105getEditorURITemplate: function() {106return this._editorURITemplate;107},108109getEditorConfigureURI: function() {110return this._editorConfigureURI;111},112113getShowPathURI: function() {114return this._showPathURI;115},116117getShowDirectoryURI: function() {118return this._showDirectoryURI;119},120121getLeftChangesetID: function() {122return this._leftID;123},124125getRightChangesetID: function() {126return this._rightID;127},128129setChangesetList: function(list) {130this._changesetList = list;131return this;132},133134setViewMenu: function(menu) {135this._viewMenu = menu;136return this;137},138139getIcon: function() {140if (!this._visible) {141return 'fa-file-o';142}143144return this._icon;145},146147getColor: function() {148if (!this._visible) {149return 'grey';150}151152return 'blue';153},154155getChangesetList: function() {156return this._changesetList;157},158159/**160* Has the content of this changeset been loaded?161*162* This method returns `true` if a request has been fired, even if the163* response has not returned yet.164*165* @return bool True if the content has been loaded.166*/167isLoaded: function() {168return this._loaded;169},170171172/**173* Configure stabilization of the document position on content load.174*175* When we dump the changeset into the document, we can try to stabilize176* the document scroll position so that the user doesn't feel like they177* are jumping around as things load in. This is generally useful when178* populating initial changes.179*180* However, if a user explicitly requests a content load by clicking a181* "Load" link or using the dropdown menu, this stabilization generally182* feels unnatural, so we don't use it in response to explicit user action.183*184* @param bool True to stabilize the next content fill.185* @return this186*/187setStabilize: function(stabilize) {188this._stabilize = stabilize;189return this;190},191192193/**194* Should this changeset load immediately when the page loads?195*196* Normally, changes load immediately, but if a diff or commit is very197* large we stop doing this and have the user load files explicitly, or198* choose to load everything.199*200* @return bool True if the changeset should load automatically when the201* page loads.202*/203shouldAutoload: function() {204return this._getNodeData().autoload;205},206207208/**209* Load this changeset, if it isn't already loading.210*211* This fires a request to fill the content of this changeset, provided212* there isn't already a request in flight. To force a reload, use213* @{method:reload}.214*215* @return this216*/217load: function() {218if (this._loaded) {219return this;220}221222return this.reload();223},224225226/**227* Reload the changeset content.228*229* This method always issues a request, even if the content is already230* loading. To load conditionally, use @{method:load}.231*232* @return this233*/234reload: function(state) {235this._loaded = true;236this._sequence++;237238var workflow = this._newReloadWorkflow(state)239.setHandler(JX.bind(this, this._onresponse, this._sequence));240241this._startContentWorkflow(workflow);242243var pht = this.getChangesetList().getTranslations();244245JX.DOM.setContent(246this._getContentFrame(),247JX.$N(248'div',249{className: 'differential-loading'},250pht('Loading...')));251252return this;253},254255_newReloadWorkflow: function(state) {256var params = this._getViewParameters(state);257return new JX.Workflow(this._renderURI, params);258},259260/**261* Load missing context in a changeset.262*263* We do this when the user clicks "Show X Lines". We also expand all of264* the missing context when they "Show All Context".265*266* @param string Line range specification, like "0-40/0-20".267* @param node Row where the context should be rendered after loading.268* @param bool True if this is a bulk load of multiple context blocks.269* @return this270*/271loadContext: function(range, target, bulk) {272var params = this._getViewParameters();273params.range = range;274275var pht = this.getChangesetList().getTranslations();276277var container = JX.DOM.scry(target, 'td')[0];278JX.DOM.setContent(container, pht('Loading...'));279JX.DOM.alterClass(target, 'differential-show-more-loading', true);280281var workflow = new JX.Workflow(this._renderURI, params)282.setHandler(JX.bind(this, this._oncontext, target));283284if (bulk) {285// If we're loading a bunch of these because the viewer clicked286// "Show All Context" or similar, use lower-priority requests287// and draw a progress bar.288this._startContentWorkflow(workflow);289} else {290// If this is a single click on a context link, use a higher priority291// load without a chrome change.292workflow.start();293}294295return this;296},297298loadAllContext: function() {299var nodes = JX.DOM.scry(this._node, 'tr', 'context-target');300for (var ii = 0; ii < nodes.length; ii++) {301var show = JX.DOM.scry(nodes[ii], 'a', 'show-more');302for (var jj = 0; jj < show.length; jj++) {303var data = JX.Stratcom.getData(show[jj]);304if (data.type != 'all') {305continue;306}307this.loadContext(data.range, nodes[ii], true);308}309}310},311312_startContentWorkflow: function(workflow) {313var routable = workflow.getRoutable();314315routable316.setPriority(500)317.setType('content')318.setKey(this._getRoutableKey());319320JX.Router.getInstance().queue(routable);321},322323getDisplayPath: function() {324return this._displayPath;325},326327/**328* Receive a response to a context request.329*/330_oncontext: function(target, response) {331// TODO: This should be better structured.332// If the response comes back with several top-level nodes, the last one333// is the actual context; the others are headers. Add any headers first,334// then copy the new rows into the document.335var markup = JX.$H(response.changeset).getFragment();336var len = markup.childNodes.length;337var diff = JX.DOM.findAbove(target, 'table', 'differential-diff');338339for (var ii = 0; ii < len - 1; ii++) {340diff.parentNode.insertBefore(markup.firstChild, diff);341}342343var table = markup.firstChild;344var root = target.parentNode;345this._moveRows(table, root, target);346root.removeChild(target);347348this._onchangesetresponse(response);349},350351_moveRows: function(src, dst, before) {352var rows = JX.DOM.scry(src, 'tr');353for (var ii = 0; ii < rows.length; ii++) {354355// Find the table this <tr /> belongs to. If it's a sub-table, like a356// table in an inline comment, don't copy it.357if (JX.DOM.findAbove(rows[ii], 'table') !== src) {358continue;359}360361if (before) {362dst.insertBefore(rows[ii], before);363} else {364dst.appendChild(rows[ii]);365}366}367},368369/**370* Get parameters which define the current rendering options.371*/372_getViewParameters: function(state) {373var parameters = {374ref: this._ref,375device: this._getDefaultDeviceRenderer()376};377378if (state) {379JX.copy(parameters, state);380}381382return parameters;383},384385/**386* Get the active @{class:JX.Routable} for this changeset.387*388* After issuing a request with @{method:load} or @{method:reload}, you389* can adjust routable settings (like priority) by querying the routable390* with this method. Note that there may not be a current routable.391*392* @return JX.Routable|null Active routable, if one exists.393*/394getRoutable: function() {395return JX.Router.getInstance().getRoutableByKey(this._getRoutableKey());396},397398getRendererKey: function() {399return this._rendererKey;400},401402_getDefaultDeviceRenderer: function() {403// NOTE: If you load the page at one device resolution and then resize to404// a different one we don't re-render the diffs, because it's a405// complicated mess and you could lose inline comments, cursor positions,406// etc.407return (JX.Device.getDevice() == 'desktop') ? '2up' : '1up';408},409410getUndoTemplates: function() {411return this._undoTemplates;412},413414getCharacterEncoding: function() {415return this._characterEncoding;416},417418getHighlight: function() {419return this._highlight;420},421422getRequestDocumentEngineKey: function() {423return this._requestDocumentEngineKey;424},425426getResponseDocumentEngineKey: function() {427return this._responseDocumentEngineKey;428},429430getAvailableDocumentEngineKeys: function() {431return this._availableDocumentEngineKeys;432},433434getSelectableItems: function() {435var items = [];436437items.push({438type: 'file',439changeset: this,440target: this,441nodes: {442begin: this._node,443end: null444}445});446447if (!this._visible) {448return items;449}450451var rows = JX.DOM.scry(this._node, 'tr');452453var blocks = [];454var block;455var ii;456var parent_node = null;457for (ii = 0; ii < rows.length; ii++) {458var type = this._getRowType(rows[ii]);459460// This row might be part of a diff inside an inline comment, showing461// an inline edit suggestion. Before we accept it as a possible target462// for selection, make sure it's a child of the right parent.463464if (parent_node === null) {465parent_node = rows[ii].parentNode;466}467468if (type !== null) {469if (rows[ii].parentNode !== parent_node) {470type = null;471}472}473474if (!block || (block.type !== type)) {475block = {476type: type,477items: []478};479blocks.push(block);480}481482block.items.push(rows[ii]);483}484485var last_inline = null;486var last_inline_item = null;487for (ii = 0; ii < blocks.length; ii++) {488block = blocks[ii];489490if (block.type == 'change') {491items.push({492type: block.type,493changeset: this,494target: block.items[0],495nodes: {496begin: block.items[0],497end: block.items[block.items.length - 1]498}499});500}501502if (block.type == 'comment') {503for (var jj = 0; jj < block.items.length; jj++) {504var inline = this.getInlineForRow(block.items[jj]);505506// When comments are being edited, they have a hidden row with507// the actual comment and then a visible row with the editor.508509// In this case, we only want to generate one item, but it should510// use the editor as a scroll target. To accomplish this, check if511// this row has the same inline as the previous row. If so, update512// the last item to use this row's nodes.513514if (inline === last_inline) {515last_inline_item.nodes.begin = block.items[jj];516last_inline_item.nodes.end = block.items[jj];517continue;518} else {519last_inline = inline;520}521522var is_saved = (!inline.isDraft() && !inline.isEditing());523524last_inline_item = {525type: block.type,526changeset: this,527target: inline,528hidden: inline.isHidden(),529collapsed: inline.isCollapsed(),530deleted: !inline.getID() && !inline.isEditing(),531nodes: {532begin: block.items[jj],533end: block.items[jj]534},535attributes: {536unsaved: inline.isEditing(),537anyDraft: inline.isDraft() || inline.isDraftDone(),538undone: (is_saved && !inline.isDone()),539done: (is_saved && inline.isDone())540}541};542543items.push(last_inline_item);544}545}546}547548return items;549},550551_getRowType: function(row) {552// NOTE: Don't do "className.indexOf()" elsewhere. This is evil legacy553// magic.554555if (row.className.indexOf('inline') !== -1) {556return 'comment';557}558559var cells = JX.DOM.scry(row, 'td');560for (var ii = 0; ii < cells.length; ii++) {561if (cells[ii].className.indexOf('old') !== -1 ||562cells[ii].className.indexOf('new') !== -1) {563return 'change';564}565}566},567568_getNodeData: function() {569return JX.Stratcom.getData(this._node);570},571572getVectors: function() {573return {574pos: JX.$V(this._node),575dim: JX.Vector.getDim(this._node)576};577},578579_onresponse: function(sequence, response) {580if (sequence != this._sequence) {581// If this isn't the most recent request, ignore it. This normally582// means the user changed view settings between the time the page loaded583// and the content filled.584return;585}586587// As we populate the changeset list, we try to hold the document scroll588// position steady, so that, e.g., users who want to leave a comment on a589// diff with a large number of changes don't constantly have the text590// area scrolled off the bottom of the screen until the entire diff loads.591//592// There are several major cases here:593//594// - If we're near the top of the document, never scroll.595// - If we're near the bottom of the document, always scroll, unless596// we have an anchor.597// - Otherwise, scroll if the changes were above (or, at least,598// almost entirely above) the viewport.599//600// We don't scroll if the changes were just near the top of the viewport601// because this makes us scroll incorrectly when an anchored change is602// visible. See T12779.603604var target = this._node;605606var old_pos = JX.Vector.getScroll();607var old_view = JX.Vector.getViewport();608var old_dim = JX.Vector.getDocument();609610// Number of pixels away from the top or bottom of the document which611// count as "nearby".612var sticky = 480;613614var near_top = (old_pos.y <= sticky);615var near_bot = ((old_pos.y + old_view.y) >= (old_dim.y - sticky));616617// If we have an anchor in the URL, never stick to the bottom of the618// page. See T11784 for discussion.619if (window.location.hash) {620near_bot = false;621}622623var target_pos = JX.Vector.getPos(target);624var target_dim = JX.Vector.getDim(target);625var target_bot = (target_pos.y + target_dim.y);626627// Detect if the changeset is entirely (or, at least, almost entirely)628// above us. The height here is roughly the height of the persistent629// banner.630var above_screen = (target_bot < old_pos.y + 64);631632// If we have a URL anchor and are currently nearby, stick to it633// no matter what.634var on_target = null;635if (window.location.hash) {636try {637var anchor = JX.$(window.location.hash.replace('#', ''));638if (anchor) {639var anchor_pos = JX.$V(anchor);640if ((anchor_pos.y > old_pos.y) &&641(anchor_pos.y < old_pos.y + 96)) {642on_target = anchor;643}644}645} catch (ignored) {646// If we have a bogus anchor, just ignore it.647}648}649650var frame = this._getContentFrame();651JX.DOM.setContent(frame, JX.$H(response.changeset));652653if (this._stabilize) {654if (on_target) {655JX.DOM.scrollToPosition(old_pos.x, JX.$V(on_target).y - 60);656} else if (!near_top) {657if (near_bot || above_screen) {658// Figure out how much taller the document got.659var delta = (JX.Vector.getDocument().y - old_dim.y);660JX.DOM.scrollToPosition(old_pos.x, old_pos.y + delta);661}662}663this._stabilize = false;664}665666this._onchangesetresponse(response);667},668669_onchangesetresponse: function(response) {670// Code shared by autoload and context responses.671672this._loadChangesetState(response);673this._rebuildAllInlines();674675JX.Stratcom.invoke('resize');676},677678_loadChangesetState: function(state) {679if (state.coverage) {680for (var k in state.coverage) {681try {682JX.DOM.replace(JX.$(k), JX.$H(state.coverage[k]));683} catch (ignored) {684// Not terribly important.685}686}687}688689if (state.undoTemplates) {690this._undoTemplates = state.undoTemplates;691}692693this._rendererKey = state.rendererKey;694this._highlight = state.highlight;695this._characterEncoding = state.characterEncoding;696this._requestDocumentEngineKey = state.requestDocumentEngineKey;697this._responseDocumentEngineKey = state.responseDocumentEngineKey;698this._availableDocumentEngineKeys = state.availableDocumentEngineKeys;699this._isHidden = state.isHidden;700701var is_hidden = !this.isVisible();702if (this._isHidden != is_hidden) {703this.setVisible(!this._isHidden);704}705706this._isLoading = false;707this.getPathView().setIsLoading(this._isLoading);708},709710_getContentFrame: function() {711return JX.DOM.find(this._node, 'div', 'changeset-view-content');712},713714_getRoutableKey: function() {715return 'changeset-view.' + this._ref + '.' + this._sequence;716},717718getInlineForRow: function(node) {719var data = JX.Stratcom.getData(node);720721if (!data.inline) {722var inline = this._newInlineForRow(node);723this.getInlines().push(inline);724}725726return data.inline;727},728729_newInlineForRow: function(node) {730return new JX.DiffInline()731.setChangeset(this)732.bindToRow(node);733},734735newInlineForRange: function(origin, target, options) {736var list = this.getChangesetList();737738var src = list.getLineNumberFromHeader(origin);739var dst = list.getLineNumberFromHeader(target);740741var changeset_id = null;742var side = list.getDisplaySideFromHeader(origin);743if (side == 'right') {744changeset_id = this.getRightChangesetID();745} else {746changeset_id = this.getLeftChangesetID();747}748749var is_new = false;750if (side == 'right') {751is_new = true;752} else if (this.getRightChangesetID() != this.getLeftChangesetID()) {753is_new = true;754}755756var data = {757origin: origin,758target: target,759number: src,760length: dst - src,761changesetID: changeset_id,762displaySide: side,763isNewFile: is_new764};765766JX.copy(data, options || {});767768var inline = new JX.DiffInline()769.setChangeset(this)770.bindToRange(data);771772this.getInlines().push(inline);773774inline.create();775776return inline;777},778779newInlineReply: function(original, state) {780var inline = new JX.DiffInline()781.setChangeset(this)782.bindToReply(original);783784this._inlines.push(inline);785786inline.create(state);787788return inline;789},790791getInlineByID: function(id) {792return this._queryInline('id', id);793},794795getInlineByPHID: function(phid) {796return this._queryInline('phid', phid);797},798799_queryInline: function(field, value) {800// First, look for the inline in the objects we've already built.801var inline = this._findInline(field, value);802if (inline) {803return inline;804}805806// If we haven't found a matching inline yet, rebuild all the inlines807// present in the document, then look again.808this._rebuildAllInlines();809return this._findInline(field, value);810},811812_findInline: function(field, value) {813var inlines = this.getInlines();814815for (var ii = 0; ii < inlines.length; ii++) {816var inline = inlines[ii];817818var target;819switch (field) {820case 'id':821target = inline.getID();822break;823case 'phid':824target = inline.getPHID();825break;826}827828if (target == value) {829return inline;830}831}832833return null;834},835836getInlines: function() {837if (this._inlines === null) {838this._rebuildAllInlines();839}840841return this._inlines;842},843844_rebuildAllInlines: function() {845this._inlines = [];846847var rows = JX.DOM.scry(this._node, 'tr');848var ii;849for (ii = 0; ii < rows.length; ii++) {850var row = rows[ii];851if (this._getRowType(row) != 'comment') {852continue;853}854855this._inlines.push(this._newInlineForRow(row));856}857},858859redrawFileTree: function() {860var inlines = this.getInlines();861var done = [];862var undone = [];863var inline;864865for (var ii = 0; ii < inlines.length; ii++) {866inline = inlines[ii];867868if (inline.isDeleted()) {869continue;870}871872if (inline.isUndo()) {873continue;874}875876if (inline.isSynthetic()) {877continue;878}879880if (inline.isEditing()) {881continue;882}883884if (!inline.getID()) {885// These are new comments which have been cancelled, and do not886// count as anything.887continue;888}889890if (inline.isDraft()) {891continue;892}893894if (!inline.isDone()) {895undone.push(inline);896} else {897done.push(inline);898}899}900901var total = done.length + undone.length;902903var hint;904var is_visible;905var is_completed;906if (total) {907if (done.length) {908hint = [done.length, '/', total];909} else {910hint = total;911}912is_visible = true;913is_completed = (done.length == total);914} else {915hint = '-';916is_visible = false;917is_completed = false;918}919920var node = this.getPathView().getInlineNode();921922JX.DOM.setContent(node, hint);923924JX.DOM.alterClass(node, 'diff-tree-path-inlines-visible', is_visible);925JX.DOM.alterClass(node, 'diff-tree-path-inlines-completed', is_completed);926},927928_onClickHeader: function(e) {929// If the user clicks the actual path name text, don't count this as930// a selection action: we want to let them select the path.931var path_name = e.getNode('changeset-header-path-name');932if (path_name) {933return;934}935936// Don't allow repeatedly clicking a header to begin a "select word" or937// "select line" operation.938if (e.getType() === 'selectstart') {939e.kill();940return;941}942943// NOTE: Don't prevent or kill the event. If the user has text selected,944// clicking a header should clear the selection (and dismiss any inline945// context menu, if one exists) as clicking elsewhere in the document946// normally would.947948if (this._isSelected) {949this.getChangesetList().selectChangeset(null);950} else {951this.select(false);952}953},954955toggleVisibility: function() {956this.setVisible(!this._visible);957958var attrs = {959hidden: this.isVisible() ? 0 : 1,960discard: 1961};962963var workflow = this._newReloadWorkflow(attrs)964.setHandler(JX.bag);965966this._startContentWorkflow(workflow);967},968969setVisible: function(visible) {970this._visible = visible;971972var diff = this._getDiffNode();973var options = this._getViewButtonNode();974var show = this._getShowButtonNode();975976if (this._visible) {977JX.DOM.show(diff);978JX.DOM.show(options);979JX.DOM.hide(show);980} else {981JX.DOM.hide(diff);982JX.DOM.hide(options);983JX.DOM.show(show);984985if (this._viewMenu) {986this._viewMenu.close();987}988}989990JX.Stratcom.invoke('resize');991992var node = this._node;993JX.DOM.alterClass(node, 'changeset-content-hidden', !this._visible);994995this.getPathView().setIsHidden(!this._visible);996},997998setIsSelected: function(is_selected) {999this._isSelected = !!is_selected;10001001var node = this._node;1002JX.DOM.alterClass(node, 'changeset-selected', this._isSelected);10031004return this;1005},10061007_getDiffNode: function() {1008if (!this._diffNode) {1009this._diffNode = JX.DOM.find(this._node, 'table', 'differential-diff');1010}1011return this._diffNode;1012},10131014_getViewButtonNode: function() {1015if (!this._viewButtonNode) {1016this._viewButtonNode = JX.DOM.find(1017this._node,1018'a',1019'differential-view-options');1020}1021return this._viewButtonNode;1022},10231024_getShowButtonNode: function() {1025if (!this._showButtonNode) {1026var pht = this.getChangesetList().getTranslations();10271028var show_button = new JX.PHUIXButtonView()1029.setIcon('fa-angle-double-down')1030.setText(pht('Show Changeset'))1031.setColor('grey');10321033var button_node = show_button.getNode();1034this._getViewButtonNode().parentNode.appendChild(button_node);10351036var onshow = JX.bind(this, this._onClickShowButton);1037JX.DOM.listen(button_node, 'click', null, onshow);10381039this._showButtonNode = button_node;1040}1041return this._showButtonNode;1042},10431044_onClickShowButton: function(e) {1045e.prevent();10461047// We're always showing the changeset, but want to make sure the state1048// change is persisted on the server.1049this.toggleVisibility();1050},10511052isVisible: function() {1053return this._visible;1054},10551056getPathView: function() {1057if (!this._pathView) {1058var view = new JX.DiffPathView()1059.setChangeset(this)1060.setPath(this._pathParts)1061.setIsLowImportance(this._isLowImportance)1062.setIsOwned(this._isOwned)1063.setIsLoading(this._isLoading);10641065view.getIcon()1066.setIcon(this._pathIconIcon)1067.setColor(this._pathIconColor);10681069this._pathView = view;1070}10711072return this._pathView;1073},10741075select: function(scroll) {1076this.getChangesetList().selectChangeset(this, scroll);1077return this;1078}1079},10801081statics: {1082getForNode: function(node) {1083var data = JX.Stratcom.getData(node);1084if (!data.changesetViewManager) {1085data.changesetViewManager = new JX.DiffChangeset(node);1086}1087return data.changesetViewManager;1088}1089}1090});109110921093