| // 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(window, GrDiffGroup, GrDiffLine) { |
| 'use strict'; |
| |
| const HTML_ENTITY_PATTERN = /[&<>"'`\/]/g; |
| const HTML_ENTITY_MAP = { |
| '&': '&', |
| '<': '<', |
| '>': '>', |
| '"': '"', |
| '\'': ''', |
| '/': '/', |
| '`': '`', |
| }; |
| |
| // Prevent redefinition. |
| if (window.GrDiffBuilder) { return; } |
| |
| const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/; |
| |
| function GrDiffBuilder(diff, comments, prefs, projectName, outputEl, layers) { |
| this._diff = diff; |
| this._comments = comments; |
| this._prefs = prefs; |
| this._projectName = projectName; |
| this._outputEl = outputEl; |
| this.groups = []; |
| |
| this.layers = layers || []; |
| |
| for (const layer of this.layers) { |
| if (layer.addListener) { |
| layer.addListener(this._handleLayerUpdate.bind(this)); |
| } |
| } |
| } |
| |
| GrDiffBuilder.LESS_THAN_CODE = '<'.charCodeAt(0); |
| GrDiffBuilder.GREATER_THAN_CODE = '>'.charCodeAt(0); |
| GrDiffBuilder.AMPERSAND_CODE = '&'.charCodeAt(0); |
| GrDiffBuilder.SEMICOLON_CODE = ';'.charCodeAt(0); |
| |
| GrDiffBuilder.LINE_FEED_HTML = |
| '<span class="style-scope gr-diff br"></span>'; |
| |
| GrDiffBuilder.GroupType = { |
| ADDED: 'b', |
| BOTH: 'ab', |
| REMOVED: 'a', |
| }; |
| |
| GrDiffBuilder.Highlights = { |
| ADDED: 'edit_b', |
| REMOVED: 'edit_a', |
| }; |
| |
| GrDiffBuilder.Side = { |
| LEFT: 'left', |
| RIGHT: 'right', |
| }; |
| |
| GrDiffBuilder.ContextButtonType = { |
| ABOVE: 'above', |
| BELOW: 'below', |
| ALL: 'all', |
| }; |
| |
| const PARTIAL_CONTEXT_AMOUNT = 10; |
| |
| /** |
| * Abstract method |
| * @param {string} outputEl |
| * @param {number} fontSize |
| */ |
| GrDiffBuilder.prototype.addColumns = function() { |
| throw Error('Subclasses must implement addColumns'); |
| }; |
| |
| /** |
| * Abstract method |
| * @param {Object} group |
| */ |
| GrDiffBuilder.prototype.buildSectionElement = function() { |
| throw Error('Subclasses must implement buildGroupElement'); |
| }; |
| |
| GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) { |
| const element = this.buildSectionElement(group); |
| this._outputEl.insertBefore(element, opt_beforeSection); |
| group.element = element; |
| }; |
| |
| GrDiffBuilder.prototype.renderSection = function(element) { |
| for (let i = 0; i < this.groups.length; i++) { |
| const group = this.groups[i]; |
| if (group.element === element) { |
| const newElement = this.buildSectionElement(group); |
| group.element.parentElement.replaceChild(newElement, group.element); |
| group.element = newElement; |
| break; |
| } |
| } |
| }; |
| |
| GrDiffBuilder.prototype.getGroupsByLineRange = function( |
| startLine, endLine, opt_side) { |
| const groups = []; |
| for (let i = 0; i < this.groups.length; i++) { |
| const group = this.groups[i]; |
| if (group.lines.length === 0) { |
| continue; |
| } |
| let groupStartLine = 0; |
| let groupEndLine = 0; |
| if (opt_side) { |
| groupStartLine = group.lineRange[opt_side].start; |
| groupEndLine = group.lineRange[opt_side].end; |
| } |
| |
| if (groupStartLine === 0) { // Line was removed or added. |
| groupStartLine = groupEndLine; |
| } |
| if (groupEndLine === 0) { // Line was removed or added. |
| groupEndLine = groupStartLine; |
| } |
| if (startLine <= groupEndLine && endLine >= groupStartLine) { |
| groups.push(group); |
| } |
| } |
| return groups; |
| }; |
| |
| GrDiffBuilder.prototype.getContentByLine = function(lineNumber, opt_side, |
| opt_root) { |
| const root = Polymer.dom(opt_root || this._outputEl); |
| const sideSelector = opt_side ? ('.' + opt_side) : ''; |
| return root.querySelector('td.lineNum[data-value="' + lineNumber + |
| '"]' + sideSelector + ' ~ td.content .contentText'); |
| }; |
| |
| /** |
| * Find line elements or line objects by a range of line numbers and a side. |
| * |
| * @param {number} start The first line number |
| * @param {number} end The last line number |
| * @param {string} opt_side The side of the range. Either 'left' or 'right'. |
| * @param {!Array<GrDiffLine>} out_lines The output list of line objects. Use |
| * null if not desired. |
| * @param {!Array<HTMLElement>} out_elements The output list of line elements. |
| * Use null if not desired. |
| */ |
| GrDiffBuilder.prototype.findLinesByRange = function(start, end, opt_side, |
| out_lines, out_elements) { |
| const groups = this.getGroupsByLineRange(start, end, opt_side); |
| for (const group of groups) { |
| let content = null; |
| for (const line of group.lines) { |
| if ((opt_side === 'left' && line.type === GrDiffLine.Type.ADD) || |
| (opt_side === 'right' && line.type === GrDiffLine.Type.REMOVE)) { |
| continue; |
| } |
| const lineNumber = opt_side === 'left' ? |
| line.beforeNumber : line.afterNumber; |
| if (lineNumber < start || lineNumber > end) { continue; } |
| |
| if (out_lines) { out_lines.push(line); } |
| if (out_elements) { |
| if (content) { |
| content = this._getNextContentOnSide(content, opt_side); |
| } else { |
| content = this.getContentByLine(lineNumber, opt_side, |
| group.element); |
| } |
| if (content) { out_elements.push(content); } |
| } |
| } |
| } |
| }; |
| |
| /** |
| * Re-renders the DIV.contentText elements for the given side and range of |
| * diff content. |
| */ |
| GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) { |
| const lines = []; |
| const elements = []; |
| let line; |
| let el; |
| this.findLinesByRange(start, end, side, lines, elements); |
| for (let i = 0; i < lines.length; i++) { |
| line = lines[i]; |
| el = elements[i]; |
| el.parentElement.replaceChild(this._createTextEl(line, side).firstChild, |
| el); |
| } |
| }; |
| |
| GrDiffBuilder.prototype.getSectionsByLineRange = function( |
| startLine, endLine, opt_side) { |
| return this.getGroupsByLineRange(startLine, endLine, opt_side).map( |
| group => { return group.element; }); |
| }; |
| |
| GrDiffBuilder.prototype._commentIsAtLineNum = function(side, lineNum) { |
| return this._commentLocations[side][lineNum] === true; |
| }; |
| |
| // TODO(wyatta): Move this completely into the processor. |
| GrDiffBuilder.prototype._insertContextGroups = function(groups, lines, |
| hiddenRange) { |
| const linesBeforeCtx = lines.slice(0, hiddenRange[0]); |
| const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]); |
| const linesAfterCtx = lines.slice(hiddenRange[1]); |
| |
| if (linesBeforeCtx.length > 0) { |
| groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx)); |
| } |
| |
| const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL); |
| ctxLine.contextGroup = |
| new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines); |
| groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL, |
| [ctxLine])); |
| |
| if (linesAfterCtx.length > 0) { |
| groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx)); |
| } |
| }; |
| |
| GrDiffBuilder.prototype._createContextControl = function(section, line) { |
| if (!line.contextGroup || !line.contextGroup.lines.length) { |
| return null; |
| } |
| |
| const td = this._createElement('td'); |
| const showPartialLinks = |
| line.contextGroup.lines.length > PARTIAL_CONTEXT_AMOUNT; |
| |
| if (showPartialLinks) { |
| td.appendChild(this._createContextButton( |
| GrDiffBuilder.ContextButtonType.ABOVE, section, line)); |
| td.appendChild(document.createTextNode(' - ')); |
| } |
| |
| td.appendChild(this._createContextButton( |
| GrDiffBuilder.ContextButtonType.ALL, section, line)); |
| |
| if (showPartialLinks) { |
| td.appendChild(document.createTextNode(' - ')); |
| td.appendChild(this._createContextButton( |
| GrDiffBuilder.ContextButtonType.BELOW, section, line)); |
| } |
| |
| return td; |
| }; |
| |
| GrDiffBuilder.prototype._createContextButton = function(type, section, line) { |
| const contextLines = line.contextGroup.lines; |
| const context = PARTIAL_CONTEXT_AMOUNT; |
| |
| const button = this._createElement('gr-button', 'showContext'); |
| button.setAttribute('link', true); |
| |
| let text; |
| const groups = []; // The groups that replace this one if tapped. |
| |
| if (type === GrDiffBuilder.ContextButtonType.ALL) { |
| text = 'Show ' + contextLines.length + ' common line'; |
| if (contextLines.length > 1) { text += 's'; } |
| groups.push(line.contextGroup); |
| } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) { |
| text = '+' + context + '↑'; |
| this._insertContextGroups(groups, contextLines, |
| [context, contextLines.length]); |
| } else if (type === GrDiffBuilder.ContextButtonType.BELOW) { |
| text = '+' + context + '↓'; |
| this._insertContextGroups(groups, contextLines, |
| [0, contextLines.length - context]); |
| } |
| |
| button.textContent = text; |
| |
| button.addEventListener('tap', e => { |
| e.detail = { |
| groups, |
| section, |
| }; |
| // Let it bubble up the DOM tree. |
| }); |
| |
| return button; |
| }; |
| |
| GrDiffBuilder.prototype._getCommentsForLine = function(comments, line, |
| opt_side) { |
| function byLineNum(lineNum) { |
| return function(c) { |
| return (c.line === lineNum) || |
| (c.line === undefined && lineNum === GrDiffLine.FILE); |
| }; |
| } |
| const leftComments = |
| comments[GrDiffBuilder.Side.LEFT].filter(byLineNum(line.beforeNumber)); |
| const rightComments = |
| comments[GrDiffBuilder.Side.RIGHT].filter(byLineNum(line.afterNumber)); |
| |
| leftComments.forEach(c => { c.__commentSide = 'left'; }); |
| rightComments.forEach(c => { c.__commentSide = 'right'; }); |
| |
| let result; |
| |
| switch (opt_side) { |
| case GrDiffBuilder.Side.LEFT: |
| result = leftComments; |
| break; |
| case GrDiffBuilder.Side.RIGHT: |
| result = rightComments; |
| break; |
| default: |
| result = leftComments.concat(rightComments); |
| break; |
| } |
| |
| return result; |
| }; |
| |
| GrDiffBuilder.prototype.createCommentThreadGroup = function(changeNum, |
| patchNum, path, isOnParent, range) { |
| const threadGroupEl = |
| document.createElement('gr-diff-comment-thread-group'); |
| threadGroupEl.changeNum = changeNum; |
| threadGroupEl.patchForNewThreads = patchNum; |
| threadGroupEl.path = path; |
| threadGroupEl.isOnParent = isOnParent; |
| threadGroupEl.projectName = this._projectName; |
| threadGroupEl.range = range; |
| return threadGroupEl; |
| }; |
| |
| GrDiffBuilder.prototype._commentThreadGroupForLine = function(line, |
| opt_side) { |
| const comments = |
| this._getCommentsForLine(this._comments, line, opt_side); |
| if (!comments || comments.length === 0) { |
| return null; |
| } |
| |
| let patchNum = this._comments.meta.patchRange.patchNum; |
| let isOnParent = comments[0].side === 'PARENT' || false; |
| if (line.type === GrDiffLine.Type.REMOVE || |
| opt_side === GrDiffBuilder.Side.LEFT) { |
| if (this._comments.meta.patchRange.basePatchNum === 'PARENT') { |
| isOnParent = true; |
| } else { |
| patchNum = this._comments.meta.patchRange.basePatchNum; |
| } |
| } |
| const threadGroupEl = this.createCommentThreadGroup( |
| this._comments.meta.changeNum, |
| patchNum, |
| this._comments.meta.path, |
| isOnParent); |
| threadGroupEl.comments = comments; |
| if (opt_side) { |
| threadGroupEl.setAttribute('data-side', opt_side); |
| } |
| return threadGroupEl; |
| }; |
| |
| GrDiffBuilder.prototype._createLineEl = function(line, number, type, |
| opt_class) { |
| const td = this._createElement('td'); |
| if (opt_class) { |
| td.classList.add(opt_class); |
| } |
| |
| if (line.type === GrDiffLine.Type.REMOVE) { |
| td.setAttribute('aria-label', `${number} removed`); |
| } else if (line.type === GrDiffLine.Type.ADD) { |
| td.setAttribute('aria-label', `${number} added`); |
| } |
| |
| if (line.type === GrDiffLine.Type.BLANK) { |
| return td; |
| } else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) { |
| td.classList.add('contextLineNum'); |
| td.setAttribute('data-value', '@@'); |
| } else if (line.type === GrDiffLine.Type.BOTH || line.type === type) { |
| td.classList.add('lineNum'); |
| td.setAttribute('data-value', number); |
| } |
| return td; |
| }; |
| |
| GrDiffBuilder.prototype._createTextEl = function(line, opt_side) { |
| const td = this._createElement('td'); |
| const text = line.text; |
| if (line.type !== GrDiffLine.Type.BLANK) { |
| td.classList.add('content'); |
| } |
| td.classList.add(line.type); |
| let html = this._escapeHTML(text); |
| html = this._addTabWrappers(html, this._prefs.tab_size); |
| if (!this._prefs.line_wrapping && |
| this._textLength(text, this._prefs.tab_size) > |
| this._prefs.line_length) { |
| html = this._addNewlines(text, html); |
| } |
| |
| const contentText = this._createElement('div', 'contentText'); |
| if (opt_side) { |
| contentText.setAttribute('data-side', opt_side); |
| } |
| |
| // If the html is equivalent to the text then it didn't get highlighted |
| // or escaped. Use textContent which is faster than innerHTML. |
| if (html === text) { |
| contentText.textContent = text; |
| } else { |
| contentText.innerHTML = html; |
| } |
| |
| for (const layer of this.layers) { |
| layer.annotate(contentText, line); |
| } |
| |
| td.appendChild(contentText); |
| |
| return td; |
| }; |
| |
| /** |
| * Returns the text length after normalizing unicode and tabs. |
| * @return {number} The normalized length of the text. |
| */ |
| GrDiffBuilder.prototype._textLength = function(text, tabSize) { |
| text = text.replace(REGEX_ASTRAL_SYMBOL, '_'); |
| let numChars = 0; |
| for (let i = 0; i < text.length; i++) { |
| if (text[i] === '\t') { |
| numChars += tabSize - (numChars % tabSize); |
| } else { |
| numChars++; |
| } |
| } |
| return numChars; |
| }; |
| |
| // Advance `index` by the appropriate number of characters that would |
| // represent one source code character and return that index. For |
| // example, for source code '<span>' the escaped html string is |
| // '<span>'. Advancing from index 0 on the prior html string would |
| // return 4, since < maps to one source code character ('<'). |
| GrDiffBuilder.prototype._advanceChar = function(html, index) { |
| // TODO(andybons): Unicode is all kinds of messed up in JS. Account for it. |
| // https://mathiasbynens.be/notes/javascript-unicode |
| |
| // Tags don't count as characters |
| while (index < html.length && |
| html.charCodeAt(index) === GrDiffBuilder.LESS_THAN_CODE) { |
| while (index < html.length && |
| html.charCodeAt(index) !== GrDiffBuilder.GREATER_THAN_CODE) { |
| index++; |
| } |
| index++; // skip the ">" itself |
| } |
| // An HTML entity (e.g., <) counts as one character. |
| if (index < html.length && |
| html.charCodeAt(index) === GrDiffBuilder.AMPERSAND_CODE) { |
| while (index < html.length && |
| html.charCodeAt(index) !== GrDiffBuilder.SEMICOLON_CODE) { |
| index++; |
| } |
| } |
| return index + 1; |
| }; |
| |
| GrDiffBuilder.prototype._advancePastTagClose = function(html, index) { |
| while (index < html.length && |
| html.charCodeAt(index) !== GrDiffBuilder.GREATER_THAN_CODE) { |
| index++; |
| } |
| return index + 1; |
| }; |
| |
| GrDiffBuilder.prototype._addNewlines = function(text, html) { |
| let htmlIndex = 0; |
| const indices = []; |
| let numChars = 0; |
| let prevHtmlIndex = 0; |
| for (let i = 0; i < text.length; i++) { |
| if (numChars > 0 && numChars % this._prefs.line_length === 0) { |
| indices.push(htmlIndex); |
| } |
| htmlIndex = this._advanceChar(html, htmlIndex); |
| if (text[i] === '\t') { |
| // Advance past tab closing tag. |
| htmlIndex = this._advancePastTagClose(html, htmlIndex); |
| // ~~ is a faster Math.floor |
| if (~~(numChars / this._prefs.line_length) !== |
| ~~((numChars + this._prefs.tab_size) / this._prefs.line_length)) { |
| // Tab crosses line limit - push it to the next line. |
| indices.push(prevHtmlIndex); |
| } |
| numChars += this._prefs.tab_size; |
| } else { |
| numChars++; |
| } |
| prevHtmlIndex = htmlIndex; |
| } |
| let result = html; |
| // Since the result string is being altered in place, start from the end |
| // of the string so that the insertion indices are not affected as the |
| // result string changes. |
| for (let i = indices.length - 1; i >= 0; i--) { |
| result = result.slice(0, indices[i]) + GrDiffBuilder.LINE_FEED_HTML + |
| result.slice(indices[i]); |
| } |
| return result; |
| }; |
| |
| /** |
| * Takes a string of text (not HTML) and returns a string of HTML with tab |
| * elements in place of tab characters. In each case tab elements are given |
| * the width needed to reach the next tab-stop. |
| * |
| * @param {string} A line of text potentially containing tab characters. |
| * @param {number} The width for tabs. |
| * @return {string} An HTML string potentially containing tab elements. |
| */ |
| GrDiffBuilder.prototype._addTabWrappers = function(line, tabSize) { |
| if (!line.length) { return ''; } |
| |
| let result = ''; |
| let offset = 0; |
| const split = line.split('\t'); |
| let width; |
| |
| for (let i = 0; i < split.length - 1; i++) { |
| offset += split[i].length; |
| width = tabSize - (offset % tabSize); |
| result += split[i] + this._getTabWrapper(width); |
| offset += width; |
| } |
| if (split.length) { |
| result += split[split.length - 1]; |
| } |
| |
| return result; |
| }; |
| |
| GrDiffBuilder.prototype._getTabWrapper = function(tabSize) { |
| // Force this to be a number to prevent arbitrary injection. |
| tabSize = +tabSize; |
| if (isNaN(tabSize)) { |
| throw Error('Invalid tab size from preferences.'); |
| } |
| |
| let str = '<span class="style-scope gr-diff tab '; |
| str += '" style="'; |
| // TODO(andybons): CSS tab-size is not supported in IE. |
| str += 'tab-size:' + tabSize + ';'; |
| str += '-moz-tab-size:' + tabSize + ';'; |
| str += '">\t</span>'; |
| return str; |
| }; |
| |
| GrDiffBuilder.prototype._createElement = function(tagName, className) { |
| const el = document.createElement(tagName); |
| // When Shady DOM is being used, these classes are added to account for |
| // Polymer's polyfill behavior. In order to guarantee sufficient |
| // specificity within the CSS rules, these are added to every element. |
| // Since the Polymer DOM utility functions (which would do this |
| // automatically) are not being used for performance reasons, this is |
| // done manually. |
| el.classList.add('style-scope', 'gr-diff'); |
| if (className) { |
| el.classList.add(className); |
| } |
| return el; |
| }; |
| |
| GrDiffBuilder.prototype._handleLayerUpdate = function(start, end, side) { |
| this._renderContentByRange(start, end, side); |
| }; |
| |
| /** |
| * Finds the next DIV.contentText element following the given element, and on |
| * the same side. Will only search within a group. |
| * @param {HTMLElement} content |
| * @param {string} side Either 'left' or 'right' |
| * @return {HTMLElement} |
| */ |
| GrDiffBuilder.prototype._getNextContentOnSide = function(content, side) { |
| throw Error('Subclasses must implement _getNextContentOnSide'); |
| }; |
| |
| /** |
| * Determines whether the given group is either totally an addition or totally |
| * a removal. |
| * @param {!Object} group (GrDiffGroup) |
| * @return {boolean} |
| */ |
| GrDiffBuilder.prototype._isTotal = function(group) { |
| return group.type === GrDiffGroup.Type.DELTA && |
| (!group.adds.length || !group.removes.length) && |
| !(!group.adds.length && !group.removes.length); |
| }; |
| |
| GrDiffBuilder.prototype._escapeHTML = function(str) { |
| return str.replace(HTML_ENTITY_PATTERN, s => { |
| return HTML_ENTITY_MAP[s]; |
| }); |
| }; |
| |
| window.GrDiffBuilder = GrDiffBuilder; |
| })(window, GrDiffGroup, GrDiffLine); |