diff --git a/cypress/integration/revision-diff.spec.js b/cypress/integration/revision-diff.spec.js index c3b78083..aa2c7236 100644 --- a/cypress/integration/revision-diff.spec.js +++ b/cypress/integration/revision-diff.spec.js @@ -1,95 +1,453 @@ /** - * 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 */ -let origin; +const $ = Cypress.$; +const origin = 'https://github.com/memononen/libtess2'; +const revision = '98c65dad5e47ad888032b6cdf556f192e0e028d0'; + +const diffsHighlightingData = { + 'unified': { + diffId: '3d4c0797cf0e89430410e088339aac384dfa4d82', + startLines: [913, 915], + endLines: [0, 979] + }, + 'split-from': { + diffId: '602cec77c3d3f41d396d9e1083a0bbce0796b505', + startLines: [192, 0], + endLines: [198, 0] + }, + 'split-to': { + diffId: '602cec77c3d3f41d396d9e1083a0bbce0796b505', + startLines: [0, 120], + endLines: [0, 130] + }, + 'split-from-top-to-bottom': { + diffId: 'a00c33990655a93aa2c821c4008bbddda812a896', + startLines: [63, 0], + endLines: [0, 68] + }, + 'split-to-top-from-bottom': { + diffId: 'a00c33990655a93aa2c821c4008bbddda812a896', + startLines: [0, 63], + endLines: [67, 0] + } +}; + let diffData; +let swh; describe('Test Diffs View', function() { - before(function() { - origin = this.origin[0]; - const url = this.Urls.browse_revision(origin.revisions[0]) + `?origin=${origin.url}`; - cy.visit(url).window().then(win => { + beforeEach(function() { + const url = this.Urls.browse_revision(revision) + `?origin=${origin}`; + cy.visit(url); + cy.window().then(win => { + swh = win.swh; cy.request(win.diffRevUrl) .then(res => { diffData = res.body; }); }); - }); - - beforeEach(function() { - const url = this.Urls.browse_revision(origin.revisions[0]) + `?origin=${origin.url}`; - cy.visit(url); cy.get('a[data-toggle="tab"]') .contains('Changes') .click(); }); it('should list all files with changes', function() { let files = new Set([]); for (let change of diffData.changes) { files.add(change.from_path); files.add(change.to_path); } for (let file of files) { cy.get('#swh-revision-changes-list a') .contains(file) .should('be.visible'); } }); it('should load diffs when scrolled down', function() { cy.get('#swh-revision-changes-list a') .each($el => { cy.get($el.attr('href')) .scrollIntoView() .find('.swh-content') .should('be.visible'); }); }); it('should compute all diffs when selected', function() { cy.get('#swh-compute-all-diffs') .click(); cy.get('#swh-revision-changes-list a') .each($el => { cy.get($el.attr('href')) .find('.swh-content') .should('be.visible'); }); }); 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'); } }); it('should load unified diff by default', function() { cy.get('#swh-compute-all-diffs') .click(); 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'); } }); 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 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 unifiedDiffHighlightingTest(diffId, startLines, endLines) { + // render diff + cy.get(`#diff_${diffId}`) + .scrollIntoView() + .get(`#${diffId}-unified-diff`) + .should('be.visible') + // ensure all asynchronous treatments in the page have been performed + // before testing diff highlighting + .then(() => { + + let startLinesStr = swh.revision.formatDiffLineNumbers(diffId, startLines[0], startLines[1]); + let endLinesStr = swh.revision.formatDiffLineNumbers(diffId, endLines[0], endLines[1]); + + // highlight a range of lines + let startElt = `#${diffId}-unified-diff .hljs-ln-numbers[data-line-number="${startLinesStr}"]`; + let endElt = `#${diffId}-unified-diff .hljs-ln-numbers[data-line-number="${endLinesStr}"]`; + cy.get(startElt).click(); + cy.get('body').type(`{shift}`, { + release: false + }); + cy.get(endElt).click(); + + // check URL fragment has been updated + const selectedLinesFragment = + swh.revision.selectedDiffLinesToFragment(startLines, endLines, true); + cy.hash().should('be.equal', `#diff_${diffId}+${selectedLinesFragment}`); + + if ($(endElt).position().top < $(startElt).position().top) { + [startLinesStr, endLinesStr] = [endLinesStr, startLinesStr]; + } + + // check lines range is highlighted + checkDiffHighlighted(`${diffId}-unified-diff`, startLinesStr, endLinesStr); + + // check selected diff lines get highlighted when reloading page + // with highlighting info in URL fragment + cy.reload(); + cy.get(`#diff_${diffId}`) + .get(`#${diffId}-unified-diff`) + .should('be.visible'); + + checkDiffHighlighted(`${diffId}-unified-diff`, startLinesStr, endLinesStr); + }); + } + + it('should highlight unified diff lines when selecting them from top to bottom', function() { + + const diffHighlightingData = diffsHighlightingData['unified']; + const diffId = diffHighlightingData.diffId; + let startLines = diffHighlightingData.startLines; + let endLines = diffHighlightingData.endLines; + + unifiedDiffHighlightingTest(diffId, startLines, endLines); + + }); + + it('should highlight unified diff lines when selecting them from bottom to top', function() { + + const diffHighlightingData = diffsHighlightingData['unified']; + const diffId = diffHighlightingData.diffId; + let startLines = diffHighlightingData.startLines; + let endLines = diffHighlightingData.endLines; + + unifiedDiffHighlightingTest(diffId, endLines, startLines); + + }); + + function singleSpitDiffHighlightingTest(diffId, startLines, endLines, to) { + + let singleDiffId = `${diffId}-from`; + if (to) { + singleDiffId = `${diffId}-to`; + } + + let startLine = startLines[0] || startLines[1]; + let endLine = endLines[0] || endLines[1]; + + // render diff + cy.get(`#diff_${diffId}`) + .scrollIntoView() + .get(`#${diffId}-unified-diff`) + .should('be.visible'); + + cy.get(`#diff_${diffId}`) + .contains('label', 'Side-by-side') + .click() + // ensure all asynchronous treatments in the page have been performed + // before testing diff highlighting + .then(() => { + // highlight a range of lines + let startElt = `#${singleDiffId} .hljs-ln-numbers[data-line-number="${startLine}"]`; + let endElt = `#${singleDiffId} .hljs-ln-numbers[data-line-number="${endLine}"]`; + cy.get(startElt).click(); + cy.get('body').type(`{shift}`, { + release: false + }); + cy.get(endElt).click(); + + const selectedLinesFragment = + swh.revision.selectedDiffLinesToFragment(startLines, endLines, false); + + // check URL fragment has been updated + cy.hash().should('be.equal', `#diff_${diffId}+${selectedLinesFragment}`); + + if ($(endElt).position().top < $(startElt).position().top) { + [startLine, endLine] = [endLine, startLine]; + } + + // check lines range is highlighted + checkDiffHighlighted(`${singleDiffId}`, startLine, endLine); + + // check selected diff lines get highlighted when reloading page + // with highlighting info in URL fragment + cy.reload(); + cy.get(`#diff_${diffId}`) + .get(`#${diffId}-split-diff`) + .get(`#${singleDiffId}`) + .should('be.visible'); + checkDiffHighlighted(`${singleDiffId}`, startLine, endLine); + }); + } + + it('should highlight split diff from lines when selecting them from top to bottom', function() { + const diffHighlightingData = diffsHighlightingData['split-from']; + const diffId = diffHighlightingData.diffId; + let startLines = diffHighlightingData.startLines; + let endLines = diffHighlightingData.endLines; + + singleSpitDiffHighlightingTest(diffId, startLines, endLines, false); + }); + + it('should highlight split diff from lines when selecting them from bottom to top', function() { + const diffHighlightingData = diffsHighlightingData['split-from']; + const diffId = diffHighlightingData.diffId; + let startLines = diffHighlightingData.startLines; + let endLines = diffHighlightingData.endLines; + + singleSpitDiffHighlightingTest(diffId, endLines, startLines, false); + }); + + it('should highlight split diff to lines when selecting them from top to bottom', function() { + const diffHighlightingData = diffsHighlightingData['split-to']; + const diffId = diffHighlightingData.diffId; + let startLines = diffHighlightingData.startLines; + let endLines = diffHighlightingData.endLines; + + singleSpitDiffHighlightingTest(diffId, startLines, endLines, true); + }); + + it('should highlight split diff to lines when selecting them from bottom to top', function() { + + const diffHighlightingData = diffsHighlightingData['split-to']; + const diffId = diffHighlightingData.diffId; + let startLines = diffHighlightingData.startLines; + let endLines = diffHighlightingData.endLines; + + singleSpitDiffHighlightingTest(diffId, endLines, startLines, true); + }); + + 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; + } + } + }); + }); + } + + function splitDiffHighlightingTest(diffId, startLines, endLines) { + // render diff + cy.get(`#diff_${diffId}`) + .scrollIntoView() + .find(`#${diffId}-unified-diff`) + .should('be.visible'); + + cy.get(`#diff_${diffId}`) + .contains('label', 'Side-by-side') + .click() + // ensure all asynchronous treatments in the page have been performed + // before testing diff highlighting + .then(() => { + + // select lines range in diff + let startElt; + if (startLines[0]) { + startElt = `#${diffId}-from .hljs-ln-numbers[data-line-number="${startLines[0]}"]`; + } else { + startElt = `#${diffId}-to .hljs-ln-numbers[data-line-number="${startLines[1]}"]`; + } + let endElt; + if (endLines[0]) { + endElt = `#${diffId}-from .hljs-ln-numbers[data-line-number="${endLines[0]}"]`; + } else { + endElt = `#${diffId}-to .hljs-ln-numbers[data-line-number="${endLines[1]}"]`; + } + + cy.get(startElt).click(); + cy.get('body').type(`{shift}`, { + release: false + }); + cy.get(endElt).click(); + + const selectedLinesFragment = + swh.revision.selectedDiffLinesToFragment(startLines, endLines, false); + + // check URL fragment has been updated + cy.hash().should('be.equal', `#diff_${diffId}+${selectedLinesFragment}`); + + // check lines range is highlighted + checkSplitDiffHighlighted(diffId, startLines, endLines); + + // check selected diff lines get highlighted when reloading page + // with highlighting info in URL fragment + cy.reload(); + cy.get(`#diff_${diffId}`) + .get(`#${diffId}-split-diff`) + .get(`#${diffId}-to`) + .should('be.visible'); + + checkSplitDiffHighlighted(diffId, startLines, endLines); + }); + } + + it('should highlight split diff from and to lines when selecting them from top-left to bottom-right', function() { + const diffHighlightingData = diffsHighlightingData['split-from-top-to-bottom']; + const diffId = diffHighlightingData.diffId; + let startLines = diffHighlightingData.startLines; + let endLines = diffHighlightingData.endLines; + + splitDiffHighlightingTest(diffId, startLines, endLines); + }); + + it('should highlight split diff from and to lines when selecting them from bottom-right to top-left', function() { + const diffHighlightingData = diffsHighlightingData['split-from-top-to-bottom']; + const diffId = diffHighlightingData.diffId; + let startLines = diffHighlightingData.startLines; + let endLines = diffHighlightingData.endLines; + + splitDiffHighlightingTest(diffId, endLines, startLines); + }); + + it('should highlight split diff from and to lines when selecting them from top-right to bottom-left', function() { + const diffHighlightingData = diffsHighlightingData['split-to-top-from-bottom']; + const diffId = diffHighlightingData.diffId; + let startLines = diffHighlightingData.startLines; + let endLines = diffHighlightingData.endLines; + + splitDiffHighlightingTest(diffId, startLines, endLines); + }); + + it('should highlight split diff from and to lines when selecting them from bottom-left to top-right', function() { + const diffHighlightingData = diffsHighlightingData['split-to-top-from-bottom']; + const diffId = diffHighlightingData.diffId; + let startLines = diffHighlightingData.startLines; + let endLines = diffHighlightingData.endLines; + + splitDiffHighlightingTest(diffId, endLines, startLines); + }); }); diff --git a/swh/web/assets/src/bundles/revision/diff-panel.ejs b/swh/web/assets/src/bundles/revision/diff-panel.ejs index 963bd3d0..28be56cb 100644 --- a/swh/web/assets/src/bundles/revision/diff-panel.ejs +++ b/swh/web/assets/src/bundles/revision/diff-panel.ejs @@ -1,50 +1,50 @@ <%# Copyright (C) 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 %> -
+
- +
<%= diffPanelTitle %>
View file
-
+
\ No newline at end of file diff --git a/swh/web/assets/src/bundles/revision/diff-utils.js b/swh/web/assets/src/bundles/revision/diff-utils.js index ad5f515c..401c2314 100644 --- a/swh/web/assets/src/bundles/revision/diff-utils.js +++ b/swh/web/assets/src/bundles/revision/diff-utils.js @@ -1,505 +1,775 @@ /** * Copyright (C) 2018-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 */ import 'waypoints/lib/jquery.waypoints'; import {swhSpinnerSrc} from 'utils/constants'; import diffPanelTemplate from './diff-panel.ejs'; // number of changed files in the revision let changes = null; let nbChangedFiles = 0; // to track the number of already computed files diffs let nbDiffsComputed = 0; // the no newline at end of file marker from Github let noNewLineMarker = '' + '' + ''; // to track the total number of added lines in files diffs let nbAdditions = 0; // to track the total number of deleted lines in files diffs let nbDeletions = 0; // to track the already computed diffs by id 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 = '#fdf3da'; +// 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) { let elementTop = $(elt).offset().top; let elementBottom = elementTop + $(elt).outerHeight(); let viewportTop = $(window).scrollTop(); let viewportBottom = viewportTop + $(window).height(); return elementBottom > viewportTop && elementTop < viewportBottom; } // to format the diffs line numbers -function formatDiffLineNumbers(fromLine, toLine, maxNumberChars) { +export function formatDiffLineNumbers(diffId, fromLine, toLine) { + const maxNumberChars = diffMaxNumberChars[diffId]; + const fromLineStr = toLnStr(fromLine); + const toLineStr = toLnStr(toLine); let ret = ''; - if (fromLine != null) { - for (let i = 0; i < (maxNumberChars - fromLine.length); ++i) { - ret += ' '; - } - ret += fromLine; - } - if (fromLine != null && toLine != null) { - ret += ' '; + for (let i = 0; i < (maxNumberChars - fromLineStr.length); ++i) { + ret += ' '; } - if (toLine != null) { - for (let i = 0; i < (maxNumberChars - toLine.length); ++i) { - ret += ' '; - } - ret += toLine; + ret += fromLineStr; + ret += ' '; + for (let i = 0; i < (maxNumberChars - toLineStr.length); ++i) { + ret += ' '; } + ret += toLineStr; return ret; } function parseDiffHunkRangeIfAny(lineText) { let baseFromLine, baseToLine; if (lineText.startsWith('@@')) { let linesInfoRegExp = new RegExp(/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@$/gm); let linesInfoRegExp2 = new RegExp(/^@@ -(\d+) \+(\d+),(\d+) @@$/gm); let linesInfoRegExp3 = new RegExp(/^@@ -(\d+),(\d+) \+(\d+) @@$/gm); let linesInfoRegExp4 = new RegExp(/^@@ -(\d+) \+(\d+) @@$/gm); let linesInfo = linesInfoRegExp.exec(lineText); let linesInfo2 = linesInfoRegExp2.exec(lineText); let linesInfo3 = linesInfoRegExp3.exec(lineText); let linesInfo4 = linesInfoRegExp4.exec(lineText); if (linesInfo) { baseFromLine = parseInt(linesInfo[1]) - 1; baseToLine = parseInt(linesInfo[3]) - 1; } else if (linesInfo2) { baseFromLine = parseInt(linesInfo2[1]) - 1; baseToLine = parseInt(linesInfo2[2]) - 1; } else if (linesInfo3) { baseFromLine = parseInt(linesInfo3[1]) - 1; baseToLine = parseInt(linesInfo3[3]) - 1; } else if (linesInfo4) { baseFromLine = parseInt(linesInfo4[1]) - 1; baseToLine = parseInt(linesInfo4[2]) - 1; } } if (baseFromLine !== undefined) { return [baseFromLine, baseToLine]; } else { return null; } } +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; + } +} + +// function to highlight a single diff line +function highlightDiffLine(diffId, i) { + let line = $(`#${diffId} .hljs-ln-line[data-line-number="${i}"]`); + let lineNumbers = $(`#${diffId} .hljs-ln-numbers[data-line-number="${i}"]`); + lineNumbers.css('color', 'black'); + lineNumbers.css('font-weight', 'bold'); + line.css('background-color', lineHighlightColor); + line.css('mix-blend-mode', 'multiply'); + return line; +} + +// 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', 'initial'); + $('.hljs-ln-line[data-line-number]').css('mix-blend-mode', 'initial'); + $('.hljs-ln-numbers[data-line-number]').css('color', '#aaa'); + $('.hljs-ln-numbers[data-line-number]').css('font-weight', 'initial'); +} + +// 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(diffId, startLines[0], startLines[1]); + let end = formatDiffLineNumbers(diffId, endLines[0], endLines[1]); + + 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; +} + +// 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) { // force diff computation ? let force = diffUrl.indexOf('force=true') !== -1; // it no forced computation and diff already computed, do nothing if (!force && computedDiffs.hasOwnProperty(diffId)) { return; } + function setLineNumbers(lnElt, lineNumbers) { + $(lnElt).attr('data-line-number', lineNumbers || ''); + $(lnElt).children().attr('data-line-number', lineNumbers || ''); + $(lnElt).siblings().attr('data-line-number', lineNumbers || ''); + } + // mark diff computation as already requested computedDiffs[diffId] = true; $(`#${diffId}-loading`).css('visibility', 'visible'); // set spinner visible while requesting diff $(`#${diffId}-loading`).css('display', 'block'); $(`#${diffId}-highlightjs`).css('display', 'none'); // request diff computation and process it fetch(diffUrl) .then(response => response.json()) .then(data => { // increment number of computed diffs ++nbDiffsComputed; // toggle the 'Compute all diffs' button if all diffs have been computed if (nbDiffsComputed === changes.length) { $('#swh-compute-all-diffs').addClass('active'); } // Large diff (> threshold) are not automatically computed, // add a button to force its computation if (data.diff_str.indexOf('Large diff') === 0) { $(`#${diffId}`)[0].innerHTML = data.diff_str + `
'; setDiffVisible(diffId); } else if (data.diff_str.indexOf('@@') !== 0) { $(`#${diffId}`).text(data.diff_str); setDiffVisible(diffId); } else { // prepare code highlighting $(`.${diffId}`).removeClass('nohighlight'); $(`.${diffId}`).addClass(data.language); // set unified diff text $(`#${diffId}`).text(data.diff_str); // code highlighting for unified diff $(`#${diffId}`).each((i, block) => { hljs.highlightBlock(block); hljs.lineNumbersBlockSync(block); }); // process unified diff lines in order to generate side-by-side diffs text // but also compute line numbers for unified and side-by-side diffs let baseFromLine = ''; let baseToLine = ''; let fromToLines = []; let fromLines = []; let toLines = []; let maxNumberChars = 0; let diffFromStr = ''; let diffToStr = ''; let linesOffset = 0; $(`#${diffId} .hljs-ln-numbers`).each((i, lnElt) => { let lnText = lnElt.nextSibling.innerText; let linesInfo = parseDiffHunkRangeIfAny(lnText); let fromLine = ''; let toLine = ''; // parsed lines info from the diff output if (linesInfo) { baseFromLine = linesInfo[0]; baseToLine = linesInfo[1]; linesOffset = 0; diffFromStr += (lnText + '\n'); diffToStr += (lnText + '\n'); fromLines.push(''); toLines.push(''); // line removed in the from file } else if (lnText.length > 0 && lnText[0] === '-') { baseFromLine = baseFromLine + 1; fromLine = baseFromLine.toString(); fromLines.push(fromLine); ++nbDeletions; diffFromStr += (lnText + '\n'); ++linesOffset; // line added in the to file } else if (lnText.length > 0 && lnText[0] === '+') { baseToLine = baseToLine + 1; toLine = baseToLine.toString(); toLines.push(toLine); ++nbAdditions; diffToStr += (lnText + '\n'); --linesOffset; // line present in both files } else { baseFromLine = baseFromLine + 1; baseToLine = baseToLine + 1; fromLine = baseFromLine.toString(); toLine = baseToLine.toString(); for (let j = 0; j < Math.abs(linesOffset); ++j) { if (linesOffset > 0) { diffToStr += '\n'; toLines.push(''); } else { diffFromStr += '\n'; fromLines.push(''); } } linesOffset = 0; diffFromStr += (lnText + '\n'); diffToStr += (lnText + '\n'); toLines.push(toLine); fromLines.push(fromLine); } if (!baseFromLine) { fromLine = ''; } if (!baseToLine) { toLine = ''; } fromToLines[i] = [fromLine, toLine]; maxNumberChars = Math.max(maxNumberChars, fromLine.length); maxNumberChars = Math.max(maxNumberChars, toLine.length); }); + diffMaxNumberChars[diffId] = maxNumberChars; + // set side-by-side diffs text $(`#${diffId}-from`).text(diffFromStr); $(`#${diffId}-to`).text(diffToStr); // code highlighting for side-by-side diffs $(`#${diffId}-from, #${diffId}-to`).each((i, block) => { hljs.highlightBlock(block); hljs.lineNumbersBlockSync(block); }); // diff highlighting for added/removed lines on top of code highlighting $(`.${diffId} .hljs-ln-numbers`).each((i, lnElt) => { let lnText = lnElt.nextSibling.innerText; if (lnText.startsWith('@@')) { $(lnElt).parent().addClass('swh-diff-lines-info'); let linesInfoText = $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').text(); $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').children().remove(); $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').text(''); $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').append(`${linesInfoText}`); } else if (lnText.length > 0 && lnText[0] === '-') { $(lnElt).parent().addClass('swh-diff-removed-line'); } else if (lnText.length > 0 && lnText[0] === '+') { $(lnElt).parent().addClass('swh-diff-added-line'); } }); // 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(diffId, fromToLines[i][0], fromToLines[i][1]); + 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: // - remove the '+' and '-' at the beginning of the diff lines // from code highlighting // - add the "no new line at end of file marker" if needed $(`.${diffId} .hljs-ln-code`).each((i, lnElt) => { if (lnElt.firstChild) { if (lnElt.firstChild.nodeName !== '#text') { let lineText = lnElt.firstChild.innerHTML; if (lineText[0] === '-' || lineText[0] === '+') { lnElt.firstChild.innerHTML = lineText.substr(1); let newTextNode = document.createTextNode(lineText[0]); $(lnElt).prepend(newTextNode); } } $(lnElt).contents().filter((i, elt) => { return elt.nodeType === 3; // Node.TEXT_NODE }).each((i, textNode) => { let swhNoNewLineMarker = '[swh-no-nl-marker]'; if (textNode.textContent.indexOf(swhNoNewLineMarker) !== -1) { textNode.textContent = textNode.textContent.replace(swhNoNewLineMarker, ''); $(lnElt).append($(noNewLineMarker)); } }); } }); // 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 + } + ); + } } }); } function setDiffVisible(diffId) { // set the unified diff visible by default $(`#${diffId}-loading`).css('display', 'none'); $(`#${diffId}-highlightjs`).css('display', 'block'); // update displayed counters $('#swh-revision-lines-added').text(`${nbAdditions} additions`); $('#swh-revision-lines-deleted').text(`${nbDeletions} deletions`); $('#swh-nb-diffs-computed').text(nbDiffsComputed); // refresh the waypoints triggering diffs computation as // the DOM layout has been updated Waypoint.refreshAll(); } // to compute all visible diffs in the viewport 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); } }); } function genDiffPanel(diffData) { let diffPanelTitle = diffData.path; if (diffData.type === 'rename') { diffPanelTitle = `${diffData.from_path} → ${diffData.to_path}`; } return diffPanelTemplate({ diffData: diffData, diffPanelTitle: diffPanelTitle, swhSpinnerSrc: swhSpinnerSrc }); } // setup waypoints to request diffs computation on the fly while scrolling function setupWaypoints() { for (let i = 0; i < changes.length; ++i) { let diffData = changes[i]; // create a waypoint that will trigger diff computation when // the top of the diff panel hits the bottom of the viewport - $(`#panel_${diffData.id}`).waypoint({ + $(`#diff_${diffData.id}`).waypoint({ handler: function() { if (isInViewport(this.element)) { - let diffId = this.element.id.replace('panel_', ''); + let diffId = this.element.id.replace('diff_', ''); computeDiff(diffsUrls[diffId], diffId); this.destroy(); } }, offset: '100%' }); // create a waypoint that will trigger diff computation when // the bottom of the diff panel hits the top of the viewport - $(`#panel_${diffData.id}`).waypoint({ + $(`#diff_${diffData.id}`).waypoint({ handler: function() { if (isInViewport(this.element)) { - let diffId = this.element.id.replace('panel_', ''); + let diffId = this.element.id.replace('diff_', ''); computeDiff(diffsUrls[diffId], diffId); this.destroy(); } }, offset: function() { return -$(this.element).height(); } }); } Waypoint.refreshAll(); } -// callback to switch from side-by-side diff to unified one -export function showUnifiedDiff(diffId) { - $(`#${diffId}-splitted-diff`).css('display', 'none'); - $(`#${diffId}-unified-diff`).css('display', 'block'); -} - -// callback to switch from unified diff to side-by-side one -export function showSplittedDiff(diffId) { - $(`#${diffId}-unified-diff`).css('display', 'none'); - $(`#${diffId}-splitted-diff`).css('display', 'block'); +function scrollToDiffPanel(diffPanelId, setHash = true) { + // disable waypoints while scrolling as we do not want to + // launch computation of diffs the user is not interested in + // (file changes list can be large) + Waypoint.disableAll(); + + $('html, body').animate( + { + scrollTop: $(diffPanelId).offset().top + }, + { + duration: 500, + complete: () => { + if (setHash) { + window.location.hash = diffPanelId; + } + // enable waypoints back after scrolling + Waypoint.enableAll(); + // compute diffs visible in the viewport + computeVisibleDiffs(); + } + }); } // callback when the user clicks on the 'Compute all diffs' button export function computeAllDiffs(event) { $(event.currentTarget).addClass('active'); for (let diffId in diffsUrls) { if (diffsUrls.hasOwnProperty(diffId)) { computeDiff(diffsUrls[diffId], diffId); } } event.stopPropagation(); } export async function initRevisionDiff(revisionMessageBody, diffRevisionUrl) { await import(/* webpackChunkName: "highlightjs" */ 'utils/highlightjs'); // callback when the 'Changes' tab is activated $(document).on('shown.bs.tab', 'a[data-toggle="tab"]', e => { if (e.currentTarget.text.trim() === 'Changes') { $('#readme-panel').css('display', 'none'); if (changes) { return; } // request computation of revision file changes list // when navigating to the 'Changes' tab and add diff panels // to the DOM when receiving the result fetch(diffRevisionUrl) .then(response => response.json()) .then(data => { changes = data.changes; nbChangedFiles = data.total_nb_changes; let changedFilesText = `${nbChangedFiles} changed file`; if (nbChangedFiles !== 1) { changedFilesText += 's'; } $('#swh-revision-changed-files').text(changedFilesText); $('#swh-total-nb-diffs').text(changes.length); $('#swh-revision-changes-list pre')[0].innerHTML = data.changes_msg; $('#swh-revision-changes-loading').css('display', 'none'); $('#swh-revision-changes-list pre').css('display', 'block'); $('#swh-compute-all-diffs').css('visibility', 'visible'); $('#swh-revision-changes-list').removeClass('in'); if (nbChangedFiles > changes.length) { $('#swh-too-large-revision-diff').css('display', 'block'); $('#swh-nb-loaded-diffs').text(changes.length); } for (let i = 0; i < changes.length; ++i) { let diffData = changes[i]; diffsUrls[diffData.id] = diffData.diff_url; $('#swh-revision-diffs').append(genDiffPanel(diffData)); } setupWaypoints(); computeVisibleDiffs(); + + if (selectedDiffLinesInfo) { + scrollToDiffPanel(selectedDiffLinesInfo.diffPanelId, false); + } + }); } else if (e.currentTarget.text.trim() === 'Files') { $('#readme-panel').css('display', 'block'); } }); $(document).ready(() => { if (revisionMessageBody.length > 0) { $('#swh-revision-message').addClass('in'); } else { $('#swh-collapse-revision-message').attr('data-toggle', ''); } - let $root = $('html, body'); - // callback when the user requests to scroll on a specific diff or back to top $('#swh-revision-changes-list a[href^="#"], #back-to-top a[href^="#"]').click(e => { let href = $.attr(e.currentTarget, 'href'); - // disable waypoints while scrolling as we do not want to - // launch computation of diffs the user is not interested in - // (file changes list can be large) - Waypoint.disableAll(); - - $root.animate( - { - scrollTop: $(href).offset().top - }, - { - duration: 500, - complete: () => { - window.location.hash = href; - // enable waypoints back after scrolling - Waypoint.enableAll(); - // compute diffs visible in the viewport - computeVisibleDiffs(); + scrollToDiffPanel(href); + return false; + }); + + // click callback for highlighting diff lines + $('body').click(evt => { + if (evt.target.classList.contains('hljs-ln-n')) { + + const diffId = $(evt.target).closest('code').prop('id'); + + const from = diffId.indexOf('-from') !== -1; + const to = diffId.indexOf('-to') !== -1; + + const lineNumbers = $(evt.target).data('line-number').toString(); + + const currentDiff = diffId.replace('-from', '').replace('-to', ''); + if (!evt.shiftKey || currentDiff !== focusedDiff || !lineNumbers.trim()) { + resetHighlightedDiffLines(); + focusedDiff = currentDiff; + } + if (currentDiff === focusedDiff && lineNumbers.trim()) { + if (!evt.shiftKey) { + startLines = parseDiffLineNumbers(lineNumbers, from, to); + highlightDiffLines(currentDiff, startLines, startLines, !from && !to); + } else if (startLines) { + resetHighlightedDiffLines(false); + endLines = parseDiffLineNumbers(lineNumbers, from, to); + highlightDiffLines(currentDiff, startLines, endLines, !from && !to); } - }); + } - return false; + } else { + resetHighlightedDiffLines(); + } }); + // if an URL fragment for highlighting a diff is present + // parse highlighting info and initiate diff loading + const fragment = window.location.hash; + if (fragment) { + const split = fragment.split('+'); + if (split.length === 2) { + selectedDiffLinesInfo = fragmentToSelectedDiffLines(split[1]); + if (selectedDiffLinesInfo) { + selectedDiffLinesInfo.diffPanelId = split[0]; + $('.nav-tabs a[href="#swh-revision-changes"]').tab('show'); + } + } + } + }); } diff --git a/swh/web/browse/views/revision.py b/swh/web/browse/views/revision.py index 17af6081..597b81a9 100644 --- a/swh/web/browse/views/revision.py +++ b/swh/web/browse/views/revision.py @@ -1,607 +1,606 @@ # Copyright (C) 2017-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 import hashlib import json import textwrap from django.http import HttpResponse from django.shortcuts import render from django.template.defaultfilters import filesizeformat from django.utils.safestring import mark_safe from swh.model.identifiers import ( swhid, CONTENT, DIRECTORY, REVISION, SNAPSHOT, ) from swh.web.browse.browseurls import browse_route from swh.web.browse.snapshot_context import get_snapshot_context from swh.web.browse.utils import ( gen_link, gen_revision_link, gen_revision_url, get_revision_log_url, get_directory_entries, gen_directory_link, request_content, prepare_content_for_display, content_display_max_size, gen_snapshot_link, get_readme_to_display, format_log_entries, gen_person_mail_link, ) from swh.web.common import service from swh.web.common.exc import NotFoundExc, handle_view_exception from swh.web.common.identifiers import get_swhids_info from swh.web.common.typing import RevisionMetadata, SWHObjectInfo from swh.web.common.utils import ( reverse, format_utc_iso_date, gen_path_info, swh_object_icons, ) def _gen_content_url(revision, query_string, path, snapshot_context): if snapshot_context: query_params = snapshot_context["query_params"] query_params["path"] = path query_params["revision"] = revision["id"] content_url = reverse("browse-origin-content", query_params=query_params) else: content_path = "%s/%s" % (revision["directory"], path) content_url = reverse( "browse-content", url_args={"query_string": query_string}, query_params={"path": content_path}, ) return content_url def _gen_diff_link(idx, diff_anchor, link_text): if idx < _max_displayed_file_diffs: return gen_link(diff_anchor, link_text) else: return link_text # TODO: put in conf _max_displayed_file_diffs = 1000 def _gen_revision_changes_list(revision, changes, snapshot_context): """ Returns a HTML string describing the file changes introduced in a revision. As this string will be displayed in the browse revision view, links to adequate file diffs are also generated. Args: revision (str): hexadecimal representation of a revision identifier changes (list): list of file changes in the revision snapshot_context (dict): optional origin context used to reverse the content urls Returns: A string to insert in a revision HTML view. """ changes_msg = [] for i, change in enumerate(changes): hasher = hashlib.sha1() from_query_string = "" to_query_string = "" diff_id = "diff-" if change["from"]: from_query_string = "sha1_git:" + change["from"]["target"] diff_id += change["from"]["target"] + "-" + change["from_path"] diff_id += "-" if change["to"]: to_query_string = "sha1_git:" + change["to"]["target"] diff_id += change["to"]["target"] + change["to_path"] change["path"] = change["to_path"] or change["from_path"] url_args = { "from_query_string": from_query_string, "to_query_string": to_query_string, } query_params = {"path": change["path"]} change["diff_url"] = reverse( "diff-contents", url_args=url_args, query_params=query_params ) hasher.update(diff_id.encode("utf-8")) diff_id = hasher.hexdigest() change["id"] = diff_id - panel_diff_link = "#panel_" + diff_id + diff_link = "#diff_" + diff_id if change["type"] == "modify": change["content_url"] = _gen_content_url( revision, to_query_string, change["to_path"], snapshot_context ) changes_msg.append( - "modified: %s" % _gen_diff_link(i, panel_diff_link, change["to_path"]) + "modified: %s" % _gen_diff_link(i, diff_link, change["to_path"]) ) elif change["type"] == "insert": change["content_url"] = _gen_content_url( revision, to_query_string, change["to_path"], snapshot_context ) changes_msg.append( - "new file: %s" % _gen_diff_link(i, panel_diff_link, change["to_path"]) + "new file: %s" % _gen_diff_link(i, diff_link, change["to_path"]) ) elif change["type"] == "delete": parent = service.lookup_revision(revision["parents"][0]) change["content_url"] = _gen_content_url( parent, from_query_string, change["from_path"], snapshot_context ) changes_msg.append( - "deleted: %s" - % _gen_diff_link(i, panel_diff_link, change["from_path"]) + "deleted: %s" % _gen_diff_link(i, diff_link, change["from_path"]) ) elif change["type"] == "rename": change["content_url"] = _gen_content_url( revision, to_query_string, change["to_path"], snapshot_context ) link_text = change["from_path"] + " → " + change["to_path"] changes_msg.append( - "renamed: %s" % _gen_diff_link(i, panel_diff_link, link_text) + "renamed: %s" % _gen_diff_link(i, diff_link, link_text) ) if not changes: changes_msg.append("No changes") return mark_safe("\n".join(changes_msg)) @browse_route( r"revision/(?P[0-9a-f]+)/diff/", view_name="diff-revision", checksum_args=["sha1_git"], ) def _revision_diff(request, sha1_git): """ Browse internal endpoint to compute revision diff """ try: revision = service.lookup_revision(sha1_git) snapshot_context = None origin_url = request.GET.get("origin_url", None) if not origin_url: origin_url = request.GET.get("origin", None) timestamp = request.GET.get("timestamp", None) visit_id = request.GET.get("visit_id", None) if origin_url: snapshot_context = get_snapshot_context( origin_url=origin_url, timestamp=timestamp, visit_id=visit_id ) except Exception as exc: return handle_view_exception(request, exc) changes = service.diff_revision(sha1_git) changes_msg = _gen_revision_changes_list(revision, changes, snapshot_context) diff_data = { "total_nb_changes": len(changes), "changes": changes[:_max_displayed_file_diffs], "changes_msg": changes_msg, } diff_data_json = json.dumps(diff_data, separators=(",", ": ")) return HttpResponse(diff_data_json, content_type="application/json") NB_LOG_ENTRIES = 100 @browse_route( r"revision/(?P[0-9a-f]+)/log/", view_name="browse-revision-log", checksum_args=["sha1_git"], ) def revision_log_browse(request, sha1_git): """ Django view that produces an HTML display of the history log for a revision identified by its id. The url that points to it is :http:get:`/browse/revision/(sha1_git)/log/` """ try: origin_url = request.GET.get("origin_url") snapshot_id = request.GET.get("snapshot") snapshot_context = None if origin_url or snapshot_id: snapshot_context = get_snapshot_context( snapshot_id=snapshot_id, origin_url=origin_url, timestamp=request.GET.get("timestamp"), visit_id=request.GET.get("visit_id"), branch_name=request.GET.get("branch"), release_name=request.GET.get("release"), revision_id=sha1_git, ) per_page = int(request.GET.get("per_page", NB_LOG_ENTRIES)) offset = int(request.GET.get("offset", 0)) revs_ordering = request.GET.get("revs_ordering", "committer_date") session_key = "rev_%s_log_ordering_%s" % (sha1_git, revs_ordering) rev_log_session = request.session.get(session_key, None) rev_log = [] revs_walker_state = None if rev_log_session: rev_log = rev_log_session["rev_log"] revs_walker_state = rev_log_session["revs_walker_state"] if len(rev_log) < offset + per_page: revs_walker = service.get_revisions_walker( revs_ordering, sha1_git, max_revs=offset + per_page + 1, state=revs_walker_state, ) rev_log += [rev["id"] for rev in revs_walker] revs_walker_state = revs_walker.export_state() revs = rev_log[offset : offset + per_page] revision_log = service.lookup_revision_multiple(revs) request.session[session_key] = { "rev_log": rev_log, "revs_walker_state": revs_walker_state, } except Exception as exc: return handle_view_exception(request, exc) revs_ordering = request.GET.get("revs_ordering", "") prev_log_url = None if len(rev_log) > offset + per_page: prev_log_url = reverse( "browse-revision-log", url_args={"sha1_git": sha1_git}, query_params={ "per_page": per_page, "offset": offset + per_page, "revs_ordering": revs_ordering, }, ) next_log_url = None if offset != 0: next_log_url = reverse( "browse-revision-log", url_args={"sha1_git": sha1_git}, query_params={ "per_page": per_page, "offset": offset - per_page, "revs_ordering": revs_ordering, }, ) revision_log_data = format_log_entries(revision_log, per_page) swh_rev_id = swhid("revision", sha1_git) return render( request, "browse/revision-log.html", { "heading": "Revision history", "swh_object_id": swh_rev_id, "swh_object_name": "Revisions history", "swh_object_metadata": None, "revision_log": revision_log_data, "revs_ordering": revs_ordering, "next_log_url": next_log_url, "prev_log_url": prev_log_url, "breadcrumbs": None, "top_right_link": None, "snapshot_context": snapshot_context, "vault_cooking": None, "show_actions": True, "swhids_info": None, }, ) @browse_route( r"revision/(?P[0-9a-f]+)/", view_name="browse-revision", checksum_args=["sha1_git"], ) def revision_browse(request, sha1_git): """ Django view that produces an HTML display of a revision identified by its id. The url that points to it is :http:get:`/browse/revision/(sha1_git)/`. """ try: revision = service.lookup_revision(sha1_git) origin_info = None snapshot_context = None origin_url = request.GET.get("origin_url") if not origin_url: origin_url = request.GET.get("origin") timestamp = request.GET.get("timestamp") visit_id = request.GET.get("visit_id") snapshot_id = request.GET.get("snapshot_id") if not snapshot_id: snapshot_id = request.GET.get("snapshot") path = request.GET.get("path") dir_id = None dirs, files = None, None content_data = {} if origin_url: try: snapshot_context = get_snapshot_context( snapshot_id=snapshot_id, origin_url=origin_url, timestamp=timestamp, visit_id=visit_id, branch_name=request.GET.get("branch"), release_name=request.GET.get("release"), revision_id=sha1_git, ) except NotFoundExc as e: raw_rev_url = reverse( "browse-revision", url_args={"sha1_git": sha1_git} ) error_message = ( "The Software Heritage archive has a revision " "with the hash you provided but the origin " "mentioned in your request appears broken: %s. " "Please check the URL and try again.\n\n" "Nevertheless, you can still browse the revision " "without origin information: %s" % (gen_link(origin_url), gen_link(raw_rev_url)) ) if str(e).startswith("Origin"): raise NotFoundExc(error_message) else: raise e origin_info = snapshot_context["origin_info"] snapshot_id = snapshot_context["snapshot_id"] elif snapshot_id: snapshot_context = get_snapshot_context(snapshot_id) if path: file_info = service.lookup_directory_with_path(revision["directory"], path) if file_info["type"] == "dir": dir_id = file_info["target"] else: query_string = "sha1_git:" + file_info["target"] content_data = request_content(query_string, raise_if_unavailable=False) else: dir_id = revision["directory"] if dir_id: path = "" if path is None else (path + "/") dirs, files = get_directory_entries(dir_id) except Exception as exc: return handle_view_exception(request, exc) revision_metadata = RevisionMetadata( object_type=REVISION, object_id=sha1_git, revision=sha1_git, revision_url=gen_revision_link(sha1_git), author=revision["author"]["fullname"] if revision["author"] else "None", author_url=gen_person_mail_link(revision["author"]) if revision["author"] else "None", committer=revision["committer"]["fullname"] if revision["committer"] else "None", committer_url=gen_person_mail_link(revision["committer"]) if revision["committer"] else "None", committer_date=format_utc_iso_date(revision["committer_date"]), date=format_utc_iso_date(revision["date"]), directory=revision["directory"], directory_url=gen_directory_link(revision["directory"]), merge=revision["merge"], metadata=json.dumps( revision["metadata"], sort_keys=True, indent=4, separators=(",", ": ") ), parents=revision["parents"], synthetic=revision["synthetic"], type=revision["type"], snapshot=snapshot_id, snapshot_url=gen_snapshot_link(snapshot_id) if snapshot_id else None, origin_url=origin_url, ) message_lines = ["None"] if revision["message"]: message_lines = revision["message"].split("\n") parents = [] for p in revision["parents"]: parent_url = gen_revision_url(p, snapshot_context) parents.append({"id": p, "url": parent_url}) path_info = gen_path_info(path) query_params = snapshot_context["query_params"] if snapshot_context else {} breadcrumbs = [] breadcrumbs.append( { "name": revision["directory"][:7], "url": reverse( "browse-revision", url_args={"sha1_git": sha1_git}, query_params=query_params, ), } ) for pi in path_info: query_params["path"] = pi["path"] breadcrumbs.append( { "name": pi["name"], "url": reverse( "browse-revision", url_args={"sha1_git": sha1_git}, query_params=query_params, ), } ) vault_cooking = { "directory_context": False, "directory_id": None, "revision_context": True, "revision_id": sha1_git, } swh_objects = [SWHObjectInfo(object_type=REVISION, object_id=sha1_git)] content = None content_size = None filename = None mimetype = None language = None readme_name = None readme_url = None readme_html = None readmes = {} error_code = 200 error_message = "" error_description = "" extra_context = dict(revision_metadata) extra_context["path"] = f"/{path}" if path else "/" if content_data: breadcrumbs[-1]["url"] = None content_size = content_data["length"] mimetype = content_data["mimetype"] if content_data["raw_data"]: content_display_data = prepare_content_for_display( content_data["raw_data"], content_data["mimetype"], path ) content = content_display_data["content_data"] language = content_display_data["language"] mimetype = content_display_data["mimetype"] if path: filename = path_info[-1]["name"] query_params["filename"] = filename filepath = "/".join(pi["name"] for pi in path_info[:-1]) extra_context["path"] = f"/{filepath}/" if filepath else "/" extra_context["filename"] = filename top_right_link = { "url": reverse( "browse-content-raw", url_args={"query_string": query_string}, query_params={"filename": filename}, ), "icon": swh_object_icons["content"], "text": "Raw File", } swh_objects.append( SWHObjectInfo(object_type=CONTENT, object_id=file_info["target"]) ) error_code = content_data["error_code"] error_message = content_data["error_message"] error_description = content_data["error_description"] else: for d in dirs: if d["type"] == "rev": d["url"] = reverse( "browse-revision", url_args={"sha1_git": d["target"]} ) else: query_params["path"] = path + d["name"] d["url"] = reverse( "browse-revision", url_args={"sha1_git": sha1_git}, query_params=query_params, ) for f in files: query_params["path"] = path + f["name"] f["url"] = reverse( "browse-revision", url_args={"sha1_git": sha1_git}, query_params=query_params, ) if f["length"] is not None: f["length"] = filesizeformat(f["length"]) if f["name"].lower().startswith("readme"): readmes[f["name"]] = f["checksums"]["sha1"] readme_name, readme_url, readme_html = get_readme_to_display(readmes) top_right_link = { "url": get_revision_log_url(sha1_git, snapshot_context), "icon": swh_object_icons["revisions history"], "text": "History", } vault_cooking["directory_context"] = True vault_cooking["directory_id"] = dir_id swh_objects.append(SWHObjectInfo(object_type=DIRECTORY, object_id=dir_id)) query_params.pop("path", None) diff_revision_url = reverse( "diff-revision", url_args={"sha1_git": sha1_git}, query_params=query_params, ) if snapshot_id: swh_objects.append(SWHObjectInfo(object_type=SNAPSHOT, object_id=snapshot_id)) swhids_info = get_swhids_info(swh_objects, snapshot_context, extra_context) heading = "Revision - %s - %s" % ( sha1_git[:7], textwrap.shorten(message_lines[0], width=70), ) if snapshot_context: context_found = "snapshot: %s" % snapshot_context["snapshot_id"] if origin_info: context_found = "origin: %s" % origin_info["url"] heading += " - %s" % context_found return render( request, "browse/revision.html", { "heading": heading, "swh_object_id": swhids_info[0]["swhid"], "swh_object_name": "Revision", "swh_object_metadata": revision_metadata, "message_header": message_lines[0], "message_body": "\n".join(message_lines[1:]), "parents": parents, "snapshot_context": snapshot_context, "dirs": dirs, "files": files, "content": content, "content_size": content_size, "max_content_size": content_display_max_size, "filename": filename, "encoding": content_data.get("encoding"), "mimetype": mimetype, "language": language, "readme_name": readme_name, "readme_url": readme_url, "readme_html": readme_html, "breadcrumbs": breadcrumbs, "top_right_link": top_right_link, "vault_cooking": vault_cooking, "diff_revision_url": diff_revision_url, "show_actions": True, "swhids_info": swhids_info, "error_code": error_code, "error_message": error_message, "error_description": error_description, }, status=error_code, )