+

>
Loading diff ...
\ 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,
)