/** * @license * 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'; // Prevent redefinition. if (window.GrDiffBuilder) { return; } /** * In JS, unicode code points above 0xFFFF occupy two elements of a string. * For example '𐀏'.length is 2. An occurence of such a code point is called a * surrogate pair. * * This regex segments a string along tabs ('\t') and surrogate pairs, since * these are two cases where '1 char' does not automatically imply '1 column'. * * TODO: For human languages whose orthographies use combining marks, this * approach won't correctly identify the grapheme boundaries. In those cases, * a grapheme consists of multiple code points that should count as only one * character against the column limit. Getting that correct (if it's desired) * is probably beyond the limits of a regex, but there are nonstandard APIs to * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs. * * Further reading: * On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode * Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries * A proposed JS API: https://github.com/tc39/proposal-intl-segmenter */ const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\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._blameInfo = null; this._parentIndex = undefined; this.layers = layers || []; if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) { throw Error('Invalid tab size from preferences.'); } if (isNaN(prefs.line_length) || prefs.line_length <= 0) { throw Error('Invalid line length from preferences.'); } for (const layer of this.layers) { if (layer.addListener) { layer.addListener(this._handleLayerUpdate.bind(this)); } } } 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 buildSectionElement'); }; 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]; if (!el) { // Cannot re-render an element if it does not exist. This can happen // if lines are collapsed and not visible on the page yet. continue; } 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; }); }; // 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); button.setAttribute('no-uppercase', 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]); } Polymer.dom(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; }; /** * @param {number} changeNum * @param {number|string} patchNum * @param {string} path * @param {boolean} isOnParent * @param {string} commentSide * @return {!Object} */ GrDiffBuilder.prototype.createCommentThreadGroup = function(changeNum, patchNum, path, isOnParent, commentSide) { const threadGroupEl = document.createElement('gr-diff-comment-thread-group'); threadGroupEl.changeNum = changeNum; threadGroupEl.commentSide = commentSide; threadGroupEl.patchForNewThreads = patchNum; threadGroupEl.path = path; threadGroupEl.isOnParent = isOnParent; threadGroupEl.projectName = this._projectName; threadGroupEl.parentIndex = this._parentIndex; return threadGroupEl; }; /** * @param {number} line * @param {string=} opt_side * @return {!Object} */ 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' || Gerrit.PatchSetBehavior.isMergeParent( this._comments.meta.patchRange.basePatchNum)) { isOnParent = true; } else { patchNum = this._comments.meta.patchRange.basePatchNum; } } const threadGroupEl = this.createCommentThreadGroup( this._comments.meta.changeNum, patchNum, this._comments.meta.path, isOnParent, opt_side); 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', '@@'); td.textContent = '@@'; } else if (line.type === GrDiffLine.Type.BOTH || line.type === type) { td.classList.add('lineNum'); td.setAttribute('data-value', number); td.textContent = number === 'FILE' ? 'File' : number; } return td; }; GrDiffBuilder.prototype._createTextEl = function(line, opt_side) { const td = this._createElement('td'); if (line.type !== GrDiffLine.Type.BLANK) { td.classList.add('content'); } td.classList.add(line.type); const lineLimit = !this._prefs.line_wrapping ? this._prefs.line_length : Infinity; const contentText = this._formatText(line.text, this._prefs.tab_size, lineLimit); if (opt_side) { contentText.setAttribute('data-side', opt_side); } for (const layer of this.layers) { layer.annotate(contentText, line); } td.appendChild(contentText); return td; }; /** * Returns a 'div' element containing the supplied |text| as its innerText, * with '\t' characters expanded to a width determined by |tabSize|, and the * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is * desired. * * @param {string} text The text to be formatted. * @param {number} tabSize The width of each tab stop. * @param {number} lineLimit The column after which to wrap lines. * @return {HTMLElement} */ GrDiffBuilder.prototype._formatText = function(text, tabSize, lineLimit) { const contentText = this._createElement('div', 'contentText'); let columnPos = 0; let textOffset = 0; for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) { if (segment) { // |segment| contains only normal characters. If |segment| doesn't fit // entirely on the current line, append chunks of |segment| followed by // line breaks. let rowStart = 0; let rowEnd = lineLimit - columnPos; while (rowEnd < segment.length) { contentText.appendChild( document.createTextNode(segment.substring(rowStart, rowEnd))); contentText.appendChild(this._createElement('span', 'br')); columnPos = 0; rowStart = rowEnd; rowEnd += lineLimit; } // Append the last part of |segment|, which fits on the current line. contentText.appendChild( document.createTextNode(segment.substring(rowStart))); columnPos += (segment.length - rowStart); textOffset += segment.length; } if (textOffset < text.length) { // Handle the special character at |textOffset|. if (text.startsWith('\t', textOffset)) { // Append a single '\t' character. let effectiveTabSize = tabSize - (columnPos % tabSize); if (columnPos + effectiveTabSize > lineLimit) { contentText.appendChild(this._createElement('span', 'br')); columnPos = 0; effectiveTabSize = tabSize; } contentText.appendChild(this._getTabWrapper(effectiveTabSize)); columnPos += effectiveTabSize; textOffset++; } else { // Append a single surrogate pair. if (columnPos >= lineLimit) { contentText.appendChild(this._createElement('span', 'br')); columnPos = 0; } contentText.appendChild(document.createTextNode( text.substring(textOffset, textOffset + 2))); textOffset += 2; columnPos += 1; } } } return contentText; }; /** * Returns a <span> element holding a '\t' character, that will visually * occupy |tabSize| many columns. * * @param {number} tabSize The effective size of this tab stop. * @return {HTMLElement} */ GrDiffBuilder.prototype._getTabWrapper = function(tabSize) { // Force this to be a number to prevent arbitrary injection. const result = this._createElement('span', 'tab'); result.style['tab-size'] = tabSize; result.style['-moz-tab-size'] = tabSize; result.innerText = '\t'; return result; }; GrDiffBuilder.prototype._createElement = function(tagName, classStr) { 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 (classStr) { for (const className of classStr.split(' ')) { 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); }; /** * Set the blame information for the diff. For any already-rendered line, * re-render its blame cell content. * @param {Object} blame */ GrDiffBuilder.prototype.setBlame = function(blame) { this._blameInfo = blame; // TODO(wyatta): make this loop asynchronous. for (const commit of blame) { for (const range of commit.ranges) { for (let i = range.start; i <= range.end; i++) { // TODO(wyatta): this query is expensive, but, when traversing a // range, the lines are consecutive, and given the previous blame // cell, the next one can be reached cheaply. const el = this._getBlameByLineNum(i); if (!el) { continue; } // Remove the element's children (if any). while (el.hasChildNodes()) { el.removeChild(el.lastChild); } const blame = this._getBlameForBaseLine(i, commit); el.appendChild(blame); } } } }; GrDiffBuilder.prototype.setParentIndex = function(index) { this._parentIndex = index; }; /** * Find the blame cell for a given line number. * @param {number} lineNum * @return {HTMLTableDataCellElement} */ GrDiffBuilder.prototype._getBlameByLineNum = function(lineNum) { const root = Polymer.dom(this._outputEl); return root.querySelector(`td.blame[data-line-number="${lineNum}"]`); }; /** * Given a base line number, return the commit containing that line in the * current set of blame information. If no blame information has been * provided, null is returned. * @param {number} lineNum * @return {Object} The commit information. */ GrDiffBuilder.prototype._getBlameCommitForBaseLine = function(lineNum) { if (!this._blameInfo) { return null; } for (const blameCommit of this._blameInfo) { for (const range of blameCommit.ranges) { if (range.start <= lineNum && range.end >= lineNum) { return blameCommit; } } } return null; }; /** * Given the number of a base line, get the content for the blame cell of that * line. If there is no blame information for that line, returns null. * @param {number} lineNum * @param {Object=} opt_commit Optionally provide the commit object, so that * it does not need to be searched. * @return {HTMLSpanElement} */ GrDiffBuilder.prototype._getBlameForBaseLine = function(lineNum, opt_commit) { const commit = opt_commit || this._getBlameCommitForBaseLine(lineNum); if (!commit) { return null; } const isStartOfRange = commit.ranges.some(r => r.start === lineNum); const date = (new Date(commit.time * 1000)).toLocaleDateString(); const blameNode = this._createElement('span', isStartOfRange ? 'startOfRange' : ''); const shaNode = this._createElement('span', 'sha'); shaNode.innerText = commit.id.substr(0, 7); blameNode.appendChild(shaNode); blameNode.append(` on ${date} by ${commit.author}`); return blameNode; }; /** * Create a blame cell for the given base line. Blame information will be * included in the cell if available. * @param {GrDiffLine} line * @return {HTMLTableDataCellElement} */ GrDiffBuilder.prototype._createBlameCell = function(line) { const blameTd = this._createElement('td', 'blame'); blameTd.setAttribute('data-line-number', line.beforeNumber); if (line.beforeNumber) { const content = this._getBlameForBaseLine(line.beforeNumber); if (content) { blameTd.appendChild(content); } } return blameTd; }; window.GrDiffBuilder = GrDiffBuilder; })(window, GrDiffGroup, GrDiffLine);