/** * @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() { 'use strict'; const WHOLE_FILE = -1; const DiffSide = { LEFT: 'left', RIGHT: 'right', }; const DiffHighlights = { ADDED: 'edit_b', REMOVED: 'edit_a', }; /** * The maximum size for an addition or removal chunk before it is broken down * into a series of chunks that are this size at most. * * Note: The value of 120 is chosen so that it is larger than the default * _asyncThreshold of 64, but feel free to tune this constant to your * performance needs. */ const MAX_GROUP_SIZE = 120; /** * Converts the API's `DiffContent`s to `GrDiffGroup`s for rendering. * * This includes a number of tasks: * - adding a group for the "File" pseudo line that file-level comments can * be attached to * - replacing unchanged parts of the diff that are outside the user's * context setting and do not have comments with a group representing the * "expand context" widget. This may require splitting a `DiffContent` so * that the part that is within the context or has comments is shown, while * the rest is not. * - splitting large `DiffContent`s to allow more granular async rendering */ Polymer({ is: 'gr-diff-processor', _legacyUndefinedCheck: true, properties: { /** * The amount of context around collapsed groups. */ context: Number, /** * The array of groups output by the processor. */ groups: { type: Array, notify: true, }, /** * Locations that should not be collapsed, including the locations of * comments. */ keyLocations: { type: Object, value() { return {left: {}, right: {}}; }, }, /** * The maximum number of lines to process synchronously. */ _asyncThreshold: { type: Number, value: 64, }, /** @type {?number} */ _nextStepHandle: Number, /** * The promise last returned from `process()` while the asynchronous * processing is running - `null` otherwise. Provides a `cancel()` * method that rejects it with `{isCancelled: true}`. * @type {?Object} */ _processPromise: { type: Object, value: null, }, _isScrolling: Boolean, }, attached() { this.listen(window, 'scroll', '_handleWindowScroll'); }, detached() { this.cancel(); this.unlisten(window, 'scroll', '_handleWindowScroll'); }, _handleWindowScroll() { this._isScrolling = true; this.debounce('resetIsScrolling', () => { this._isScrolling = false; }, 50); }, /** * Asynchronously process the diff object into groups. As it processes, it * will splice groups into the `groups` property of the component. * @return {Promise} A promise that resolves when the diff is completely * processed. */ process(content, isBinary) { // Cancel any still running process() calls, because they append to the // same groups field. this.cancel(); this.groups = []; this.push('groups', this._makeFileComments()); // If it's a binary diff, we won't be rendering hunks of text differences // so finish processing. if (isBinary) { return Promise.resolve(); } this._processPromise = util.makeCancelable( new Promise(resolve => { const state = { lineNums: {left: 0, right: 0}, sectionIndex: 0, }; content = this._splitLargeChunks(content); content = this._splitUnchangedChunksWithKeyLocations(content); let currentBatch = 0; const nextStep = () => { if (this._isScrolling) { this._nextStepHandle = this.async(nextStep, 100); return; } // If we are done, resolve the promise. if (state.sectionIndex >= content.length) { resolve(this.groups); this._nextStepHandle = null; return; } // Process the next section and incorporate the result. const result = this._processNext(state, content); for (const group of result.groups) { this.push('groups', group); currentBatch += group.lines.length; } state.lineNums.left += result.lineDelta.left; state.lineNums.right += result.lineDelta.right; // Increment the index and recurse. state.sectionIndex = result.newSectionIndex; if (currentBatch >= this._asyncThreshold) { currentBatch = 0; this._nextStepHandle = this.async(nextStep, 1); } else { nextStep.call(this); } }; nextStep.call(this); })); return this._processPromise .finally(() => { this._processPromise = null; }); }, /** * Cancel any jobs that are running. */ cancel() { if (this._nextStepHandle != null) { this.cancelAsync(this._nextStepHandle); this._nextStepHandle = null; } if (this._processPromise) { this._processPromise.cancel(); } }, /** * Process the next uncollapsible section, or the next collapsible sections. * * @param {!Object} state * @param {!Array} sections * @return {{lineDelta: {left: number, right: number}, groups: !Array, newSectionIndex: number}} */ _processNext(state, sections) { const firstUncollapsibleSectionIndex = this._firstUncollapsibleSectionIndex(sections, state.sectionIndex); if (firstUncollapsibleSectionIndex === state.sectionIndex) { const section = sections[state.sectionIndex]; return { lineDelta: { left: this._linesLeft(section).length, right: this._linesRight(section).length, }, groups: [this._sectionToGroup( section, state.lineNums.left + 1, state.lineNums.right + 1)], newSectionIndex: state.sectionIndex + 1, }; } return this._processCollapsibleSections( state, sections, firstUncollapsibleSectionIndex); }, _linesLeft(section) { return section.ab || section.a || []; }, _linesRight(section) { return section.ab || section.b || []; }, _firstUncollapsibleSectionIndex(sections, offset) { let sectionIndex = offset; while (sectionIndex < sections.length && this._isCollapsibleSection(sections[sectionIndex])) { sectionIndex++; } return sectionIndex; }, _isCollapsibleSection(section) { return (section.ab || section.common) && !section.keyLocation; }, /** * Process a stretch of collapsible sections. * * Outputs up to three groups: * 1) Visible context before the hidden common code, unless it's the * very beginning of the file. * 2) Context hidden behind a context bar, unless empty. * 3) Visible context after the hidden common code, unless it's the very * end of the file. * * @param {!Object} state * @param {!Array} sections * @param {number} firstUncollapsibleSectionIndex * @return {{lineDelta: {left: number, right: number}, groups: !Array, newSectionIndex: number}} */ _processCollapsibleSections( state, sections, firstUncollapsibleSectionIndex) { const collapsibleSections = sections.slice( state.sectionIndex, firstUncollapsibleSectionIndex); const lineCount = collapsibleSections.reduce( (sum, section) => sum + this._commonSectionLength(section), 0); let groups = this._sectionsToGroups( collapsibleSections, state.lineNums.left + 1, state.lineNums.right + 1); if (this.context !== WHOLE_FILE) { const hiddenStart = state.sectionIndex === 0 ? 0 : this.context; const hiddenEnd = lineCount - ( firstUncollapsibleSectionIndex === sections.length ? 0 : this.context); groups = GrDiffGroup.hideInContextControl( groups, hiddenStart, hiddenEnd); } return { lineDelta: { left: lineCount, right: lineCount, }, groups, newSectionIndex: firstUncollapsibleSectionIndex, }; }, _commonSectionLength(section) { console.assert(section.ab || section.common); console.assert( !section.a || (section.b && section.a.length === section.b.length), `common section needs same number of a and b lines: `, section); return this._linesLeft(section).length; }, /** * @param {!Array} sections * @param {number} offsetLeft * @param {number} offsetRight * @return {!Array} (GrDiffGroup) */ _sectionsToGroups(sections, offsetLeft, offsetRight) { return sections.map(section => { const group = this._sectionToGroup(section, offsetLeft, offsetRight); const sectionLength = this._commonSectionLength(section); offsetLeft += sectionLength; offsetRight += sectionLength; return group; }); }, /** * @param {!Object} section * @param {number} offsetLeft * @param {number} offsetRight * @return {!Object} (GrDiffGroup) */ _sectionToGroup(section, offsetLeft, offsetRight) { const type = section.ab ? GrDiffGroup.Type.BOTH : GrDiffGroup.Type.DELTA; const lines = this._linesFromSection(section, offsetLeft, offsetRight); const group = new GrDiffGroup(type, lines); group.keyLocation = section.keyLocation; group.dueToRebase = section.due_to_rebase; group.ignoredWhitespaceOnly = section.common; return group; }, _linesFromSection(section, offsetLeft, offsetRight) { if (section.ab) { return section.ab.map((row, i) => this._lineFromRow( GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i)); } let lines = []; if (section.a) { // Avoiding a.push(...b) because that causes callstack overflows for // large b, which can occur when large files are added removed. lines = lines.concat(this._linesFromRows( GrDiffLine.Type.REMOVE, section.a, offsetLeft, section[DiffHighlights.REMOVED])); } if (section.b) { // Avoiding a.push(...b) because that causes callstack overflows for // large b, which can occur when large files are added removed. lines = lines.concat(this._linesFromRows( GrDiffLine.Type.ADD, section.b, offsetRight, section[DiffHighlights.ADDED])); } return lines; }, /** * @return {!Array} Array of GrDiffLines */ _linesFromRows(lineType, rows, offset, opt_highlights) { // Normalize highlights if they have been passed. if (opt_highlights) { opt_highlights = this._normalizeIntralineHighlights(rows, opt_highlights); } return rows.map((row, i) => this._lineFromRow(lineType, offset, offset, row, i, opt_highlights)); }, /** * @param {string} type (GrDiffLine.Type) * @param {number} offsetLeft * @param {number} offsetRight * @param {string} row * @param {number} i * @param {!Array=} opt_highlights * @return {!Object} (GrDiffLine) */ _lineFromRow(type, offsetLeft, offsetRight, row, i, opt_highlights) { const line = new GrDiffLine(type); line.text = row; if (type !== GrDiffLine.Type.ADD) line.beforeNumber = offsetLeft + i; if (type !== GrDiffLine.Type.REMOVE) line.afterNumber = offsetRight + i; if (opt_highlights) { line.highlights = opt_highlights.filter(hl => hl.contentIndex === i); } return line; }, _makeFileComments() { const line = new GrDiffLine(GrDiffLine.Type.BOTH); line.beforeNumber = GrDiffLine.FILE; line.afterNumber = GrDiffLine.FILE; return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]); }, /** * Split chunks into smaller chunks of the same kind. * * This is done to prevent doing too much work on the main thread in one * uninterrupted rendering step, which would make the browser unresponsive. * * Note that in the case of unmodified chunks, we only split chunks if the * context is set to file (because otherwise they are split up further down * the processing into the visible and hidden context), and only split it * into 2 chunks, one max sized one and the rest (for reasons that are * unclear to me). * * @param {!Array} chunks Chunks as returned from the server * @return {!Array} Finer grained chunks. */ _splitLargeChunks(chunks) { const newChunks = []; for (const chunk of chunks) { if (!chunk.ab) { for (const group of this._breakdownGroup(chunk)) { newChunks.push(group); } continue; } // If the context is set to "whole file", then break down the shared // chunks so they can be rendered incrementally. Note: this is not // enabled for any other context preference because manipulating the // chunks in this way violates assumptions by the context grouper logic. if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) { // Split large shared groups in two, where the first is the maximum // group size. newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)}); newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)}); } else { newChunks.push(chunk); } } return newChunks; }, /** * In order to show key locations, such as comments, out of the bounds of * the selected context, treat them as separate chunks within the model so * that the content (and context surrounding it) renders correctly. * @param {!Array} chunks DiffContents as returned from server. * @return {!Array} Finer grained DiffContents. */ _splitUnchangedChunksWithKeyLocations(chunks) { const result = []; let leftLineNum = 1; let rightLineNum = 1; for (const chunk of chunks) { // If it isn't a common chunk, append it as-is and update line numbers. if (!chunk.ab && !chunk.common) { if (chunk.a) { leftLineNum += chunk.a.length; } if (chunk.b) { rightLineNum += chunk.b.length; } result.push(chunk); continue; } if (chunk.common && chunk.a.length != chunk.b.length) { throw new Error( 'DiffContent with common=true must always have equal length'); } const numLines = this._commonSectionLength(chunk); const chunkEnds = this._findChunkEndsAtKeyLocations( numLines, leftLineNum, rightLineNum); leftLineNum += numLines; rightLineNum += numLines; if (chunk.ab) { result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds) .map(({lines, keyLocation}) => Object.assign({}, chunk, {ab: lines, keyLocation}))); } else if (chunk.common) { const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds); const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds); result.push(...aChunks.map(({lines, keyLocation}, i) => Object.assign( {}, chunk, {a: lines, b: bChunks[i].lines, keyLocation}))); } } return result; }, /** * @return {!Array<{offset: number, keyLocation: boolean}>} Offsets of the * new chunk ends, including whether it's a key location. */ _findChunkEndsAtKeyLocations(numLines, leftOffset, rightOffset) { const result = []; let lastChunkEnd = 0; for (let i=0; i lastChunkEnd) { result.push({offset: i, keyLocation: false}); lastChunkEnd = i; } // Add the non-collapse line as its own chunk. result.push({offset: i + 1, keyLocation: true}); } } if (numLines > lastChunkEnd) { result.push({offset: numLines, keyLocation: false}); } return result; }, _splitAtChunkEnds(lines, chunkEnds) { const result = []; let lastChunkEndOffset = 0; for (const {offset, keyLocation} of chunkEnds) { result.push( {lines: lines.slice(lastChunkEndOffset, offset), keyLocation}); lastChunkEndOffset = offset; } return result; }, /** * The `highlights` array consists of a list of * 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(content, highlights) { let contentIndex = 0; let idx = 0; const normalized = []; for (const hl of highlights) { let line = content[contentIndex] + '\n'; let j = 0; while (j < hl[0]) { if (idx === line.length) { idx = 0; line = content[++contentIndex] + '\n'; continue; } idx++; j++; } let lineHighlight = { 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, startIndex: idx, }; continue; } idx++; j++; } lineHighlight.endIndex = idx; normalized.push(lineHighlight); } return normalized; }, /** * If a group is an addition or a removal, break it down into smaller groups * of that type using the MAX_GROUP_SIZE. If the group is a shared section * or a delta it is returned as the single element of the result array. * @param {!Object} group A raw chunk from a diff response. * @return {!Array>} */ _breakdownGroup(group) { let key = null; if (group.a && !group.b) { key = 'a'; } else if (group.b && !group.a) { key = 'b'; } else if (group.ab) { key = 'ab'; } if (!key) { return [group]; } return this._breakdown(group[key], MAX_GROUP_SIZE) .map(subgroupLines => { const subGroup = {}; subGroup[key] = subgroupLines; if (group.due_to_rebase) { subGroup.due_to_rebase = true; } return subGroup; }); }, /** * Given an array and a size, return an array of arrays where no inner array * is larger than that size, preserving the original order. * @param {!Array} array * @param {number} size * @return {!Array>} * @template T */ _breakdown(array, size) { if (!array.length) { return []; } if (array.length < size) { return [array]; } const head = array.slice(0, array.length - size); const tail = array.slice(array.length - size); return this._breakdown(head, size).concat([tail]); }, }); })();