diff --git a/cypress/integration/revision-diff.spec.js b/cypress/integration/revision-diff.spec.js --- a/cypress/integration/revision-diff.spec.js +++ b/cypress/integration/revision-diff.spec.js @@ -1,12 +1,15 @@ /** - * Copyright (C) 2019 The Software Heritage developers + * Copyright (C) 2019-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ +const $ = Cypress.$; + let origin; let diffData; +let swh; describe('Test Diffs View', function() { before(function() { @@ -14,6 +17,7 @@ const url = this.Urls.browse_revision(origin.revisions[0]) + `?origin=${origin.url}`; cy.visit(url).window().then(win => { + swh = win.swh; cy.request(win.diffRevUrl) .then(res => { diffData = res.body; @@ -65,7 +69,7 @@ it('should have correct links in diff file names', function() { for (let change of diffData.changes) { - cy.get(`#swh-revision-changes-list a[href="#panel_${change.id}"`) + cy.get(`#swh-revision-changes-list a[href="#diff_${change.id}"`) .should('be.visible'); } }); @@ -76,7 +80,7 @@ for (let change of diffData.changes) { cy.get(`#${change.id}-unified-diff`) .should('be.visible'); - cy.get(`#${change.id}-splitted-diff`) + cy.get(`#${change.id}-split-diff`) .should('not.be.visible'); } }); @@ -84,12 +88,316 @@ it('should switch between unified and side-by-side diff when selected', function() { // Test for first diff const id = diffData.changes[0].id; - cy.get(`#panel_${id}`) + cy.get(`#diff_${id}`) .contains('label', 'Side-by-side') .click(); - cy.get(`#${id}-splitted-diff`) + cy.get(`#${id}-split-diff`) .should('be.visible') .get(`#${id}-unified-diff`) .should('not.be.visible'); }); + + function selectLnElt(lnElts) { + let lnElt = lnElts[Math.floor(Math.random() * lnElts.length)]; + while (!$(lnElt).data('line-number').toString().trim()) { + lnElt = lnElts[Math.floor(Math.random() * lnElts.length)]; + } + return lnElt; + } + + function checkDiffHighlighted(diffId, start, end) { + cy.get(`#${diffId} .hljs-ln-line`) + .then(lines => { + let inHighlightedRange = false; + for (let line of lines) { + const lnNumber = $(line).data('line-number'); + if (lnNumber === start || lnNumber === end) { + inHighlightedRange = true; + } + const mixBlendMode = $(line).css('mix-blend-mode'); + if (inHighlightedRange && parseInt(lnNumber)) { + assert.equal(mixBlendMode, 'multiply'); + } else { + assert.equal(mixBlendMode, 'normal'); + } + if (lnNumber === end) { + inHighlightedRange = false; + } + } + }); + } + + function checkSplitDiffHighlighted(diffId, startLines, endLines) { + let left, right; + if (startLines[0] && endLines[1]) { + left = startLines[0]; + right = endLines[1]; + } else { + left = endLines[0]; + right = startLines[1]; + } + + cy.get(`#${diffId}-from .hljs-ln-line`) + .then(fromLines => { + cy.get(`#${diffId}-to .hljs-ln-line`) + .then(toLines => { + const leftLine = $(`#${diffId}-from .hljs-ln-line[data-line-number="${left}"]`); + const rightLine = $(`#${diffId}-to .hljs-ln-line[data-line-number="${right}"]`); + const leftLineAbove = $(leftLine).position().top < $(rightLine).position().top; + let inHighlightedRange = false; + for (let i = 0; i < Math.max(fromLines.length, toLines.length); ++i) { + const fromLn = fromLines[i]; + const toLn = toLines[i]; + const fromLnNumber = $(fromLn).data('line-number'); + const toLnNumber = $(toLn).data('line-number'); + + if ((leftLineAbove && fromLnNumber === left) || + (!leftLineAbove && toLnNumber === right) || + (leftLineAbove && toLnNumber === right) || + (!leftLineAbove && fromLnNumber === left)) { + inHighlightedRange = true; + } + + if (fromLn) { + const fromMixBlendMode = $(fromLn).css('mix-blend-mode'); + if (inHighlightedRange && fromLnNumber) { + assert.equal(fromMixBlendMode, 'multiply'); + } else { + assert.equal(fromMixBlendMode, 'normal'); + } + } + + if (toLn) { + const toMixBlendMode = $(toLn).css('mix-blend-mode'); + if (inHighlightedRange && toLnNumber) { + assert.equal(toMixBlendMode, 'multiply'); + } else { + assert.equal(toMixBlendMode, 'normal'); + } + } + + if ((leftLineAbove && toLnNumber === right) || + (!leftLineAbove && fromLnNumber === left)) { + inHighlightedRange = false; + } + } + }); + }); + } + + it('should highlight unified diff lines when selecting them', function() { + const randomDiff = diffData.changes[Math.floor(Math.random() * diffData.changes.length)]; + + // render diff + cy.get(`#diff_${randomDiff.id}`) + .scrollIntoView() + .get(`#${randomDiff.id}-unified-diff`) + .should('be.visible'); + + cy.get(`#${randomDiff.id}-unified-diff .hljs-ln-n`) + .then(lnElts => { + // select random lines range in diff + let startElt = selectLnElt(lnElts); + let endElt = selectLnElt(lnElts); + cy.get(startElt).click(); + cy.get('body').type(`{shift}`, { + release: false + }); + cy.get(endElt).click(); + + let startLinesStr = $(startElt).data('line-number'); + let endLinesStr = $(endElt).data('line-number'); + + let startLines = swh.revision.parseDiffLineNumbers(startLinesStr, false, false); + let endLines = swh.revision.parseDiffLineNumbers(endLinesStr, false, false); + + const selectedLinesFragment = + swh.revision.selectedDiffLinesToFragment(startLines, endLines, true); + + // check URL fragment has been updated + cy.hash().should('be.equal', `#diff_${randomDiff.id}+${selectedLinesFragment}`); + + if ($(endElt).position().top < $(startElt).position().top) { + [startLinesStr, endLinesStr] = [endLinesStr, startLinesStr]; + } + + // check lines range is highlighted + checkDiffHighlighted(`${randomDiff.id}-unified-diff`, startLinesStr, endLinesStr); + + // check selected diff lines get highlighted when reloading page + // with highlighting info in URL fragment + cy.reload(); + cy.get(`#diff_${randomDiff.id}`) + .get(`#${randomDiff.id}-unified-diff`) + .should('be.visible'); + + checkDiffHighlighted(`${randomDiff.id}-unified-diff`, startLinesStr, endLinesStr); + }); + + }); + + it('should highlight split diff from lines when selecting them', function() { + const randomDiff = diffData.changes[Math.floor(Math.random() * diffData.changes.length)]; + + // render diff + cy.get(`#diff_${randomDiff.id}`) + .scrollIntoView() + .get(`#${randomDiff.id}-unified-diff`) + .should('be.visible'); + + cy.get(`#diff_${randomDiff.id}`) + .contains('label', 'Side-by-side') + .click(); + + cy.get(`#${randomDiff.id}-from .hljs-ln-n`) + .then(lnElts => { + // select random lines range in diff + let startElt = selectLnElt(lnElts); + let endElt = selectLnElt(lnElts); + cy.get(startElt).click(); + cy.get('body').type(`{shift}`, { + release: false + }); + cy.get(endElt).click(); + + let startLinesStr = $(startElt).data('line-number').toString(); + let endLinesStr = $(endElt).data('line-number').toString(); + + let startLines = swh.revision.parseDiffLineNumbers(startLinesStr, true, false); + let endLines = swh.revision.parseDiffLineNumbers(endLinesStr, true, false); + + const selectedLinesFragment = + swh.revision.selectedDiffLinesToFragment(startLines, endLines, false); + + // check URL fragment has been updated + cy.hash().should('be.equal', `#diff_${randomDiff.id}+${selectedLinesFragment}`); + + if ($(endElt).position().top < $(startElt).position().top) { + [startLines, endLines] = [endLines, startLines]; + } + + // check lines range is highlighted + checkDiffHighlighted(`${randomDiff.id}-from`, startLines[0], endLines[0]); + + // check selected diff lines get highlighted when reloading page + // with highlighting info in URL fragment + cy.reload(); + cy.get(`#diff_${randomDiff.id}`) + .get(`#${randomDiff.id}-split-diff`) + .get(`#${randomDiff.id}-from`) + .should('be.visible'); + checkDiffHighlighted(`${randomDiff.id}-from`, startLines[0], endLines[0]); + }); + + }); + + it('should highlight split diff to lines when selecting them', function() { + const randomDiff = diffData.changes[Math.floor(Math.random() * diffData.changes.length)]; + + // render diff + cy.get(`#diff_${randomDiff.id}`) + .scrollIntoView() + .get(`#${randomDiff.id}-unified-diff`) + .should('be.visible'); + + cy.get(`#diff_${randomDiff.id}`) + .contains('label', 'Side-by-side') + .click(); + + cy.get(`#${randomDiff.id}-to .hljs-ln-n`) + .then(lnElts => { + // select random lines range in diff + let startElt = selectLnElt(lnElts); + let endElt = selectLnElt(lnElts); + cy.get(startElt).click(); + cy.get('body').type(`{shift}`, { + release: false + }); + cy.get(endElt).click(); + + let startLinesStr = $(startElt).data('line-number').toString(); + let endLinesStr = $(endElt).data('line-number').toString(); + + let startLines = swh.revision.parseDiffLineNumbers(startLinesStr, false, true); + let endLines = swh.revision.parseDiffLineNumbers(endLinesStr, false, true); + + const selectedLinesFragment = + swh.revision.selectedDiffLinesToFragment(startLines, endLines, false); + + // check URL fragment has been updated + cy.hash().should('be.equal', `#diff_${randomDiff.id}+${selectedLinesFragment}`); + + if ($(endElt).position().top < $(startElt).position().top) { + [startLines, endLines] = [endLines, startLines]; + } + + // check lines range is highlighted + checkDiffHighlighted(`${randomDiff.id}-to`, startLines[1], endLines[1]); + + // check selected diff lines get highlighted when reloading page + // with highlighting info in URL fragment + cy.reload(); + cy.get(`#diff_${randomDiff.id}`) + .get(`#${randomDiff.id}-split-diff`) + .get(`#${randomDiff.id}-to`) + .should('be.visible'); + checkDiffHighlighted(`${randomDiff.id}-to`, startLines[1], endLines[1]); + }); + + }); + + it('should highlight split diff from and to lines when selecting them', function() { + const randomDiff = diffData.changes[Math.floor(Math.random() * diffData.changes.length)]; + + // render diff + cy.get(`#diff_${randomDiff.id}`) + .scrollIntoView() + .find(`#${randomDiff.id}-unified-diff`) + .should('be.visible'); + + cy.get(`#diff_${randomDiff.id}`) + .contains('label', 'Side-by-side') + .click(); + + cy.get(`#${randomDiff.id}-from .hljs-ln-n`) + .then(fromLnElts => { + cy.get(`#${randomDiff.id}-to .hljs-ln-n`).then(toLnElts => { + + // select random lines range in diff + let startElt = selectLnElt(fromLnElts); + let endElt = selectLnElt(toLnElts); + + cy.get(startElt).click(); + cy.get('body').type(`{shift}`, { + release: false + }); + cy.get(endElt).click(); + + let startLinesStr = $(startElt).data('line-number').toString(); + let endLinesStr = $(endElt).data('line-number').toString(); + + let startLines = swh.revision.parseDiffLineNumbers(startLinesStr, true, false); + let endLines = swh.revision.parseDiffLineNumbers(endLinesStr, false, true); + + const selectedLinesFragment = + swh.revision.selectedDiffLinesToFragment(startLines, endLines, false); + + // check URL fragment has been updated + cy.hash().should('be.equal', `#diff_${randomDiff.id}+${selectedLinesFragment}`); + + // check lines range is highlighted + checkSplitDiffHighlighted(randomDiff.id, startLines, endLines); + + // check selected diff lines get highlighted when reloading page + // with highlighting info in URL fragment + cy.reload(); + cy.get(`#diff_${randomDiff.id}`) + .get(`#${randomDiff.id}-split-diff`) + .get(`#${randomDiff.id}-to`) + .should('be.visible'); + + checkSplitDiffHighlighted(randomDiff.id, startLines, endLines); + }); + }); + }); }); diff --git a/cypress/support/index.js b/cypress/support/index.js --- a/cypress/support/index.js +++ b/cypress/support/index.js @@ -70,7 +70,7 @@ path: 'Source', id: 'cd19126d815470b28919d64b2a8e6a3e37f900dd' }], - revisions: [], + revisions: ['98c65dad5e47ad888032b6cdf556f192e0e028d0'], invalidSubDir: 'Source1' }, { url: 'https://github.com/wcoder/highlightjs-line-numbers.js', diff --git a/swh/web/assets/src/bundles/revision/diff-utils.js b/swh/web/assets/src/bundles/revision/diff-utils.js --- a/swh/web/assets/src/bundles/revision/diff-utils.js +++ b/swh/web/assets/src/bundles/revision/diff-utils.js @@ -30,6 +30,17 @@ let computedDiffs = {}; // map a diff id to its computation url let diffsUrls = {}; +// to keep track of diff lines to highlight +let startLines = null; +let endLines = null; +// map max line numbers characters to diff +const diffMaxNumberChars = {}; +// focused diff for highlighting +let focusedDiff = null; +// highlighting color +const lineHighlightColor = 'rgba(255, 219, 130, 0.6)'; +// might contain diff lines to highlight parsed from URL fragment +let selectedDiffLinesInfo; // to check if a DOM element is in the viewport function isInViewport(elt) { @@ -95,6 +106,202 @@ } } +// function to highlight a single diff line +function highlightDiffLine(diffId, i) { + let lineTd = $(`#${diffId} .hljs-ln-line[data-line-number="${i}"]`); + lineTd.css('background-color', lineHighlightColor); + lineTd.css('mix-blend-mode', 'multiply'); + return lineTd; +} + +function toLnInt(lnStr) { + return lnStr ? parseInt(lnStr) : 0; +}; + +function toLnStr(lnInt) { + return lnInt ? lnInt.toString() : ''; +}; + +// parse diff line numbers to an int array [from, to] +export function parseDiffLineNumbers(lineNumbersStr, from, to) { + let lines; + if (!from && !to) { + lines = lineNumbersStr.replace(/[ ]+/g, ' ').split(' '); + if (lines.length > 2) { + lines.shift(); + } + lines = lines.map(x => toLnInt(x)); + } else { + let lineNumber = toLnInt(lineNumbersStr.trim()); + if (from) { + lines = [lineNumber, 0]; + } else if (to) { + lines = [0, lineNumber]; + } + } + return lines; +} + +// serialize selected line numbers range to string for URL fragment +export function selectedDiffLinesToFragment(startLines, endLines, unified) { + let selectedLinesFragment = ''; + selectedLinesFragment += `F${startLines[0] || 0}`; + selectedLinesFragment += `T${startLines[1] || 0}`; + selectedLinesFragment += `-F${endLines[0] || 0}`; + selectedLinesFragment += `T${endLines[1] || 0}`; + if (unified) { + selectedLinesFragment += '-unified'; + } else { + selectedLinesFragment += '-split'; + } + return selectedLinesFragment; +} + +// parse selected lines from URL fragment +export function fragmentToSelectedDiffLines(fragment) { + const RE_LINES = /F([0-9]+)T([0-9]+)-F([0-9]+)T([0-9]+)-([a-z]+)/; + const matchObj = RE_LINES.exec(fragment); + if (matchObj.length === 6) { + return { + startLines: [parseInt(matchObj[1]), parseInt(matchObj[2])], + endLines: [parseInt(matchObj[3]), parseInt(matchObj[4])], + unified: matchObj[5] === 'unified' + }; + } else { + return null; + } +} + +// highlight lines in a diff, return first highlighted line numbers element +function highlightDiffLines(diffId, startLines, endLines, unified) { + let firstHighlightedLine; + // unified diff case + if (unified) { + let start = formatDiffLineNumbers( + toLnStr(startLines[0]), toLnStr(startLines[1]), diffMaxNumberChars[diffId]); + let end = formatDiffLineNumbers( + toLnStr(endLines[0]), toLnStr(endLines[1]), diffMaxNumberChars[diffId]); + + const startLine = $(`#${diffId} .hljs-ln-line[data-line-number="${start}"]`); + const endLine = $(`#${diffId} .hljs-ln-line[data-line-number="${end}"]`); + if ($(endLine).position().top < $(startLine).position().top) { + [start, end] = [end, start]; + firstHighlightedLine = endLine; + } else { + firstHighlightedLine = startLine; + } + const lineTd = highlightDiffLine(diffId, start); + let tr = $(lineTd).closest('tr'); + let lineNumbers = $(tr).children('.hljs-ln-line').data('line-number').toString(); + while (lineNumbers !== end) { + if (lineNumbers.trim()) { + highlightDiffLine(diffId, lineNumbers); + } + tr = $(tr).next(); + lineNumbers = $(tr).children('.hljs-ln-line').data('line-number').toString(); + } + highlightDiffLine(diffId, end); + + // split diff case + } else { + // highlight only from part of the diff + if (startLines[0] && endLines[0]) { + const start = Math.min(startLines[0], endLines[0]); + const end = Math.max(startLines[0], endLines[0]); + for (let i = start; i <= end; ++i) { + highlightDiffLine(`${diffId}-from`, i); + } + firstHighlightedLine = $(`#${diffId}-from .hljs-ln-line[data-line-number="${start}"]`); + // highlight only to part of the diff + } else if (startLines[1] && endLines[1]) { + const start = Math.min(startLines[1], endLines[1]); + const end = Math.max(startLines[1], endLines[1]); + for (let i = start; i <= end; ++i) { + highlightDiffLine(`${diffId}-to`, i); + } + firstHighlightedLine = $(`#${diffId}-to .hljs-ln-line[data-line-number="${start}"]`); + // highlight both part of the diff + } else { + let left, right; + if (startLines[0] && endLines[1]) { + left = startLines[0]; + right = endLines[1]; + } else { + left = endLines[0]; + right = startLines[1]; + } + + const leftLine = $(`#${diffId}-from .hljs-ln-line[data-line-number="${left}"]`); + const rightLine = $(`#${diffId}-to .hljs-ln-line[data-line-number="${right}"]`); + const leftLineAbove = $(leftLine).position().top < $(rightLine).position().top; + + if (leftLineAbove) { + firstHighlightedLine = leftLine; + } else { + firstHighlightedLine = rightLine; + } + + let fromTr = $(`#${diffId}-from tr`).first(); + let fromLn = $(fromTr).children('.hljs-ln-line').data('line-number'); + let toTr = $(`#${diffId}-to tr`).first(); + let toLn = $(toTr).children('.hljs-ln-line').data('line-number'); + let canHighlight = false; + + while (true) { + if (leftLineAbove && fromLn === left) { + canHighlight = true; + } else if (!leftLineAbove && toLn === right) { + canHighlight = true; + } + + if (canHighlight && fromLn) { + highlightDiffLine(`${diffId}-from`, fromLn); + } + + if (canHighlight && toLn) { + highlightDiffLine(`${diffId}-to`, toLn); + } + + if ((leftLineAbove && toLn === right) || (!leftLineAbove && fromLn === left)) { + break; + } + + fromTr = $(fromTr).next(); + fromLn = $(fromTr).children('.hljs-ln-line').data('line-number'); + toTr = $(toTr).next(); + toLn = $(toTr).children('.hljs-ln-line').data('line-number'); + } + + } + } + + let selectedLinesFragment = selectedDiffLinesToFragment(startLines, endLines, unified); + window.location.hash = `diff_${diffId}+${selectedLinesFragment}`; + return firstHighlightedLine; +} + +// function to reset highlighting +function resetHighlightedDiffLines(resetVars = true) { + if (resetVars) { + focusedDiff = null; + startLines = null; + endLines = null; + } + $('.hljs-ln-line[data-line-number]').css('background-color', 'inherit'); +} + +// callback to switch from side-by-side diff to unified one +export function showUnifiedDiff(diffId) { + $(`#${diffId}-split-diff`).css('display', 'none'); + $(`#${diffId}-unified-diff`).css('display', 'block'); +} + +// callback to switch from unified diff to side-by-side one +export function showSplitDiff(diffId) { + $(`#${diffId}-unified-diff`).css('display', 'none'); + $(`#${diffId}-split-diff`).css('display', 'block'); +} + // to compute diff and process it for display export function computeDiff(diffUrl, diffId) { @@ -230,6 +437,8 @@ maxNumberChars = Math.max(maxNumberChars, toLine.length); }); + diffMaxNumberChars[diffId] = maxNumberChars; + // set side-by-side diffs text $(`#${diffId}-from`).text(diffFromStr); $(`#${diffId}-to`).text(diffToStr); @@ -259,28 +468,27 @@ } }); + function setLineNumbers(lnElt, lineNumbers) { + $(lnElt).attr('data-line-number', lineNumbers); + $(lnElt).children().attr('data-line-number', lineNumbers); + $(lnElt).siblings().attr('data-line-number', lineNumbers); + } + // set line numbers for unified diff $(`#${diffId} .hljs-ln-numbers`).each((i, lnElt) => { - $(lnElt).children().attr( - 'data-line-number', - formatDiffLineNumbers(fromToLines[i][0], fromToLines[i][1], - maxNumberChars)); + const lineNumbers = formatDiffLineNumbers( + fromToLines[i][0], fromToLines[i][1], maxNumberChars); + setLineNumbers(lnElt, lineNumbers); }); // set line numbers for the from side-by-side diff $(`#${diffId}-from .hljs-ln-numbers`).each((i, lnElt) => { - $(lnElt).children().attr( - 'data-line-number', - formatDiffLineNumbers(fromLines[i], null, - maxNumberChars)); + setLineNumbers(lnElt, fromLines[i]); }); // set line numbers for the to side-by-side diff $(`#${diffId}-to .hljs-ln-numbers`).each((i, lnElt) => { - $(lnElt).children().attr( - 'data-line-number', - formatDiffLineNumbers(null, toLines[i], - maxNumberChars)); + setLineNumbers(lnElt, toLines[i]); }); // last processing: @@ -311,11 +519,30 @@ // hide the diff mode switch button in case of not generated diffs if (data.diff_str.indexOf('Diffs are not generated for non textual content') !== 0) { - $(`#panel_${diffId} .diff-styles`).css('visibility', 'visible'); + $(`#diff_${diffId} .diff-styles`).css('visibility', 'visible'); } setDiffVisible(diffId); + // highlight diff lines if provided in URL fragment + if (selectedDiffLinesInfo && + selectedDiffLinesInfo.diffPanelId.indexOf(diffId) !== -1) { + if (!selectedDiffLinesInfo.unified) { + showSplitDiff(diffId); + } + const firstHighlightedLine = highlightDiffLines( + diffId, selectedDiffLinesInfo.startLines, + selectedDiffLinesInfo.endLines, selectedDiffLinesInfo.unified); + + $('html, body').animate( + { + scrollTop: firstHighlightedLine.offset().top - 50 + }, + { + duration: 500 + } + ); + } }); }); } @@ -341,7 +568,7 @@ function computeVisibleDiffs() { $('.swh-file-diff-panel').each((i, elt) => { if (isInViewport(elt)) { - let diffId = elt.id.replace('panel_', ''); + let diffId = elt.id.replace('diff_', ''); computeDiff(diffsUrls[diffId], diffId); } }); @@ -353,19 +580,19 @@ diffPanelTitle = `${diffData.from_path} → ${diffData.to_path}`; } let diffPanelHtml = - `
+ `
- +
${diffPanelTitle}
@@ -373,7 +600,7 @@
-
+