// Copyright (C) 2016 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. (function() { 'use strict'; Polymer({ is: 'gr-diff', /** * Fired when the diff is rendered. * * @event render */ properties: { availablePatches: Array, changeNum: String, /* * A single object to encompass basePatchNum and patchNum is used * so that both can be set at once without incremental observers * firing after each property changes. */ patchRange: Object, path: String, prefs: { type: Object, notify: true, }, projectConfig: Object, _prefsReady: { type: Object, readOnly: true, value: function() { return new Promise(function(resolve) { this._resolvePrefsReady = resolve; }.bind(this)); }, }, _baseComments: Array, _comments: Array, _drafts: Array, _baseDrafts: Array, /** * Base (left side) comments and drafts grouped by line number. * Only used for initial rendering. */ _groupedBaseComments: { type: Object, value: function() { return {}; }, }, /** * Comments and drafts (right side) grouped by line number. * Only used for initial rendering. */ _groupedComments: { type: Object, value: function() { return {}; }, }, _diffResponse: Object, _diff: { type: Object, value: function() { return {}; }, }, _loggedIn: { type: Boolean, value: false, }, _initialRenderComplete: { type: Boolean, value: false, }, _loading: { type: Boolean, value: true, }, _savedPrefs: Object, _diffPreferencesPromise: Object, // Used for testing. }, observers: [ '_prefsChanged(prefs.*)', ], ready: function() { app.accountReady.then(function() { this._loggedIn = app.loggedIn; }.bind(this)); }, scrollToLine: function(lineNum) { // TODO(andybons): Should this always be the right side? this.$.rightDiff.scrollToLine(lineNum); }, scrollToNextDiffChunk: function() { this.$.rightDiff.scrollToNextDiffChunk(); }, scrollToPreviousDiffChunk: function() { this.$.rightDiff.scrollToPreviousDiffChunk(); }, scrollToNextCommentThread: function() { this.$.rightDiff.scrollToNextCommentThread(); }, scrollToPreviousCommentThread: function() { this.$.rightDiff.scrollToPreviousCommentThread(); }, reload: function() { this._loading = true; // If a diff takes a considerable amount of time to render, the previous // diff can end up showing up while the DOM is constructed. Clear the // content on a reload to prevent this. this._diff = { leftSide: [], rightSide: [], }; var diffLoaded = this._getDiff().then(function(diff) { this._diffResponse = diff; }.bind(this)); var promises = [ this._prefsReady, diffLoaded, ]; return app.accountReady.then(function() { promises.push(this._getDiffComments().then(function(res) { this._baseComments = res.baseComments; this._comments = res.comments; }.bind(this))); if (!app.loggedIn) { this._baseDrafts = []; this._drafts = []; } else { promises.push(this._getDiffDrafts().then(function(res) { this._baseDrafts = res.baseComments; this._drafts = res.comments; }.bind(this))); } return Promise.all(promises).then(function() { this._render(); this._loading = false; }.bind(this)).catch(function(err) { this._loading = false; alert('Oops. Something went wrong. Check the console and bug the ' + 'PolyGerrit team for assistance.'); throw err; }.bind(this)); }.bind(this)); }, _getDiff: function() { return this.$.restAPI.getDiff( this.changeNum, this.patchRange.basePatchNum, this.patchRange.patchNum, this.path); }, _getDiffComments: function() { return this.$.restAPI.getDiffComments( this.changeNum, this.patchRange.basePatchNum, this.patchRange.patchNum, this.path); }, _getDiffDrafts: function() { return this.$.restAPI.getDiffDrafts( this.changeNum, this.patchRange.basePatchNum, this.patchRange.patchNum, this.path); }, showDiffPreferences: function() { this.$.prefsOverlay.open(); }, _prefsChanged: function(changeRecord) { if (this._initialRenderComplete) { this._render(); } this._resolvePrefsReady(changeRecord.base); }, _render: function() { this._groupCommentsAndDrafts(); this._processContent(); // Allow for the initial rendering to complete before firing the event. this.async(function() { this.fire('render', null, {bubbles: false}); }.bind(this), 1); this._initialRenderComplete = true; }, _handlePrefsTap: function(e) { e.preventDefault(); // TODO(andybons): This is not supported in IE. Implement a polyfill. // NOTE: Object.assign is NOT automatically a deep copy. If prefs adds // an object as a value, it must be marked enumerable. this._savedPrefs = Object.assign({}, this.prefs); this.$.prefsOverlay.open(); }, _handlePrefsSave: function(e) { e.stopPropagation(); var el = Polymer.dom(e).rootTarget; el.disabled = true; app.accountReady.then(function() { if (!this._loggedIn) { el.disabled = false; this.$.prefsOverlay.close(); return; } this._saveDiffPreferences().then(function() { this.$.prefsOverlay.close(); el.disabled = false; }.bind(this)).catch(function(err) { el.disabled = false; alert('Oops. Something went wrong. Check the console and bug the ' + 'PolyGerrit team for assistance.'); throw err; }); }.bind(this)); }, _saveDiffPreferences: function() { var xhr = document.createElement('gr-request'); this._diffPreferencesPromise = xhr.send({ method: 'PUT', url: '/accounts/self/preferences.diff', body: this.prefs, }); return this._diffPreferencesPromise; }, _handlePrefsCancel: function(e) { e.stopPropagation(); this.prefs = this._savedPrefs; this.$.prefsOverlay.close(); }, _handleExpandContext: function(e) { var ctx = e.detail.context; var contextControlIndex = -1; for (var i = ctx.start; i <= ctx.end; i++) { this._diff.leftSide[i].hidden = false; this._diff.rightSide[i].hidden = false; if (this._diff.leftSide[i].type == 'CONTEXT_CONTROL' && this._diff.rightSide[i].type == 'CONTEXT_CONTROL') { contextControlIndex = i; } } this._diff.leftSide[contextControlIndex].hidden = true; this._diff.rightSide[contextControlIndex].hidden = true; this.$.leftDiff.hideElementsWithIndex(contextControlIndex); this.$.rightDiff.hideElementsWithIndex(contextControlIndex); this.$.leftDiff.renderLineIndexRange(ctx.start, ctx.end); this.$.rightDiff.renderLineIndexRange(ctx.start, ctx.end); }, _handleThreadHeightChange: function(e) { var index = e.detail.index; var diffEl = Polymer.dom(e).rootTarget; var otherSide = diffEl == this.$.leftDiff ? this.$.rightDiff : this.$.leftDiff; var threadHeight = e.detail.height; var otherSideHeight; if (otherSide.content[index].type == 'COMMENT_THREAD') { otherSideHeight = otherSide.getRowNaturalHeight(index); } else { otherSideHeight = otherSide.getRowHeight(index); } var maxHeight = Math.max(threadHeight, otherSideHeight); this.$.leftDiff.setRowHeight(index, maxHeight); this.$.rightDiff.setRowHeight(index, maxHeight); }, _handleAddDraft: function(e) { var insertIndex = e.detail.index + 1; var diffEl = Polymer.dom(e).rootTarget; var content = diffEl.content; if (content[insertIndex] && content[insertIndex].type == 'COMMENT_THREAD') { // A thread is already here. Do nothing. return; } var comment = { type: 'COMMENT_THREAD', comments: [{ __draft: true, __draftID: Math.random().toString(36), line: e.detail.line, path: this.path, }] }; if (diffEl == this.$.leftDiff && this.patchRange.basePatchNum == 'PARENT') { comment.comments[0].side = 'PARENT'; comment.patchNum = this.patchRange.patchNum; } if (content[insertIndex] && content[insertIndex].type == 'FILLER') { content[insertIndex] = comment; diffEl.rowUpdated(insertIndex); } else { content.splice(insertIndex, 0, comment); diffEl.rowInserted(insertIndex); } var otherSide = diffEl == this.$.leftDiff ? this.$.rightDiff : this.$.leftDiff; if (otherSide.content[insertIndex] == null || otherSide.content[insertIndex].type != 'COMMENT_THREAD') { otherSide.content.splice(insertIndex, 0, { type: 'FILLER', }); otherSide.rowInserted(insertIndex); } }, _handleRemoveThread: function(e) { var diffEl = Polymer.dom(e).rootTarget; var otherSide = diffEl == this.$.leftDiff ? this.$.rightDiff : this.$.leftDiff; var index = e.detail.index; if (otherSide.content[index].type == 'FILLER') { otherSide.content.splice(index, 1); otherSide.rowRemoved(index); diffEl.content.splice(index, 1); diffEl.rowRemoved(index); } else if (otherSide.content[index].type == 'COMMENT_THREAD') { diffEl.content[index] = {type: 'FILLER'}; diffEl.rowUpdated(index); var height = otherSide.setRowNaturalHeight(index); diffEl.setRowHeight(index, height); } else { throw Error('A thread cannot be opposite anything but filler or ' + 'another thread'); } }, _processContent: function() { var leftSide = []; var rightSide = []; var initialLineNum = 0 + (this._diffResponse.content.skip || 0); var ctx = { hidingLines: false, lastNumLinesHidden: 0, left: { lineNum: initialLineNum, }, right: { lineNum: initialLineNum, } }; var content = this._breakUpCommonChunksWithComments(ctx, this._diffResponse.content); var context = this.prefs.context; if (context == -1) { // Show the entire file. context = Infinity; } for (var i = 0; i < content.length; i++) { if (i == 0) { ctx.skipRange = [0, context]; } else if (i == content.length - 1) { ctx.skipRange = [context, 0]; } else { ctx.skipRange = [context, context]; } ctx.diffChunkIndex = i; this._addDiffChunk(ctx, content[i], leftSide, rightSide); } this._diff = { leftSide: leftSide, rightSide: rightSide, }; }, // In order to show comments out of the bounds of the selected context, // treat them as diffs within the model so that the content (and context // surrounding it) renders correctly. _breakUpCommonChunksWithComments: function(ctx, content) { var result = []; var leftLineNum = ctx.left.lineNum; var rightLineNum = ctx.right.lineNum; for (var i = 0; i < content.length; i++) { if (!content[i].ab) { result.push(content[i]); if (content[i].a) { leftLineNum += content[i].a.length; } if (content[i].b) { rightLineNum += content[i].b.length; } continue; } var chunk = content[i].ab; var currentChunk = {ab: []}; for (var j = 0; j < chunk.length; j++) { leftLineNum++; rightLineNum++; if (this._groupedBaseComments[leftLineNum] == null && this._groupedComments[rightLineNum] == null) { currentChunk.ab.push(chunk[j]); } else { if (currentChunk.ab && currentChunk.ab.length > 0) { result.push(currentChunk); currentChunk = {ab: []}; } // Append an annotation to indicate that this line should not be // highlighted even though it's implied with both `a` and `b` // defined. This is needed since there may be two lines that // should be highlighted but are equal (blank lines, for example). result.push({ __noHighlight: true, a: [chunk[j]], b: [chunk[j]], }); } } if (currentChunk.ab != null && currentChunk.ab.length > 0) { result.push(currentChunk); } } return result; }, _groupCommentsAndDrafts: function() { this._baseDrafts.forEach(function(d) { d.__draft = true; }); this._drafts.forEach(function(d) { d.__draft = true; }); var allLeft = this._baseComments.concat(this._baseDrafts); var allRight = this._comments.concat(this._drafts); var leftByLine = {}; var rightByLine = {}; var mapFunc = function(byLine) { return function(c) { // File comments/drafts are grouped with line 1 for now. var line = c.line || 1; if (byLine[line] == null) { byLine[line] = []; } byLine[line].push(c); }; }; allLeft.forEach(mapFunc(leftByLine)); allRight.forEach(mapFunc(rightByLine)); this._groupedBaseComments = leftByLine; this._groupedComments = rightByLine; }, _addContextControl: function(ctx, leftSide, rightSide) { var numLinesHidden = ctx.lastNumLinesHidden; var leftStart = leftSide.length - numLinesHidden; var leftEnd = leftSide.length; var rightStart = rightSide.length - numLinesHidden; var rightEnd = rightSide.length; if (leftStart != rightStart || leftEnd != rightEnd) { throw Error( 'Left and right ranges for context control should be equal:' + 'Left: [' + leftStart + ', ' + leftEnd + '] ' + 'Right: [' + rightStart + ', ' + rightEnd + ']'); } var obj = { type: 'CONTEXT_CONTROL', numLines: numLinesHidden, start: leftStart, end: leftEnd, }; // NOTE: Be careful, here. This object is meant to be immutable. If the // object is altered within one side's array it will reflect the // alterations in another. leftSide.push(obj); rightSide.push(obj); }, _addCommonDiffChunk: function(ctx, chunk, leftSide, rightSide) { for (var i = 0; i < chunk.ab.length; i++) { var numLines = Math.ceil( this._visibleLineLength(chunk.ab[i]) / this.prefs.line_length); var hidden = i >= ctx.skipRange[0] && i < chunk.ab.length - ctx.skipRange[1]; if (ctx.hidingLines && hidden == false) { // No longer hiding lines. Add a context control. this._addContextControl(ctx, leftSide, rightSide); ctx.lastNumLinesHidden = 0; } ctx.hidingLines = hidden; if (hidden) { ctx.lastNumLinesHidden++; } // Blank lines within a diff content array indicate a newline. leftSide.push({ type: 'CODE', hidden: hidden, content: chunk.ab[i] || '\n', numLines: numLines, lineNum: ++ctx.left.lineNum, }); rightSide.push({ type: 'CODE', hidden: hidden, content: chunk.ab[i] || '\n', numLines: numLines, lineNum: ++ctx.right.lineNum, }); this._addCommentsIfPresent(ctx, leftSide, rightSide); } if (ctx.lastNumLinesHidden > 0) { this._addContextControl(ctx, leftSide, rightSide); } }, _addDiffChunk: function(ctx, chunk, leftSide, rightSide) { if (chunk.ab) { this._addCommonDiffChunk(ctx, chunk, leftSide, rightSide); return; } var leftHighlights = []; if (chunk.edit_a) { leftHighlights = this._normalizeIntralineHighlights(chunk.a, chunk.edit_a); } var rightHighlights = []; if (chunk.edit_b) { rightHighlights = this._normalizeIntralineHighlights(chunk.b, chunk.edit_b); } var aLen = (chunk.a && chunk.a.length) || 0; var bLen = (chunk.b && chunk.b.length) || 0; var maxLen = Math.max(aLen, bLen); for (var i = 0; i < maxLen; i++) { var hasLeftContent = chunk.a && i < chunk.a.length; var hasRightContent = chunk.b && i < chunk.b.length; var leftContent = hasLeftContent ? chunk.a[i] : ''; var rightContent = hasRightContent ? chunk.b[i] : ''; var highlight = !chunk.__noHighlight; var maxNumLines = this._maxLinesSpanned(leftContent, rightContent); if (hasLeftContent) { leftSide.push({ type: 'CODE', content: leftContent || '\n', numLines: maxNumLines, lineNum: ++ctx.left.lineNum, highlight: highlight, intraline: highlight && leftHighlights.filter(function(hl) { return hl.contentIndex == i; }), }); } else { leftSide.push({ type: 'FILLER', numLines: maxNumLines, }); } if (hasRightContent) { rightSide.push({ type: 'CODE', content: rightContent || '\n', numLines: maxNumLines, lineNum: ++ctx.right.lineNum, highlight: highlight, intraline: highlight && rightHighlights.filter(function(hl) { return hl.contentIndex == i; }), }); } else { rightSide.push({ type: 'FILLER', numLines: maxNumLines, }); } this._addCommentsIfPresent(ctx, leftSide, rightSide); } }, _addCommentsIfPresent: function(ctx, leftSide, rightSide) { var leftComments = this._groupedBaseComments[ctx.left.lineNum]; var rightComments = this._groupedComments[ctx.right.lineNum]; if (leftComments) { var thread = { type: 'COMMENT_THREAD', comments: leftComments, }; if (this.patchRange.basePatchNum == 'PARENT') { thread.patchNum = this.patchRange.patchNum; } leftSide.push(thread); } if (rightComments) { rightSide.push({ type: 'COMMENT_THREAD', comments: rightComments, }); } if (leftComments && !rightComments) { rightSide.push({type: 'FILLER'}); } else if (!leftComments && rightComments) { leftSide.push({type: 'FILLER'}); } this._groupedBaseComments[ctx.left.lineNum] = null; this._groupedComments[ctx.right.lineNum] = null; }, // The `highlights` array consists of a list of <skip length, mark length> // pairs, where the skip length is the number of characters between the // end of the previous edit and the start of this edit, and the mark // length is the number of edited characters following the skip. The start // of the edits is from the beginning of the related diff content lines. // // Note that the implied newline character at the end of each line is // included in the length calculation, and thus it is possible for the // edits to span newlines. // // A line highlight object consists of three fields: // - contentIndex: The index of the diffChunk `content` field (the line // being referred to). // - startIndex: Where the highlight should begin. // - endIndex: (optional) Where the highlight should end. If omitted, the // highlight is meant to be a continuation onto the next line. _normalizeIntralineHighlights: function(content, highlights) { var contentIndex = 0; var idx = 0; var normalized = []; for (var i = 0; i < highlights.length; i++) { var line = content[contentIndex] + '\n'; var hl = highlights[i]; var j = 0; while (j < hl[0]) { if (idx == line.length) { idx = 0; line = content[++contentIndex] + '\n'; continue; } idx++; j++; } var lineHighlight = { contentIndex: contentIndex, startIndex: idx, }; j = 0; while (line && j < hl[1]) { if (idx == line.length) { idx = 0; line = content[++contentIndex] + '\n'; normalized.push(lineHighlight); lineHighlight = { contentIndex: contentIndex, startIndex: idx, }; continue; } idx++; j++; } lineHighlight.endIndex = idx; normalized.push(lineHighlight); } return normalized; }, _visibleLineLength: function(contents) { // http://jsperf.com/performance-of-match-vs-split var numTabs = contents.split('\t').length - 1; return contents.length - numTabs + (this.prefs.tab_size * numTabs); }, _maxLinesSpanned: function(left, right) { return Math.max( Math.ceil(this._visibleLineLength(left) / this.prefs.line_length), Math.ceil(this._visibleLineLength(right) / this.prefs.line_length)); }, }); })();