diffs: added diff navigation to improve UX when browisng the full context diffs.

This commit is contained in:
Marcin Kuzminski 2020-07-13 11:12:21 +02:00
parent 9cadf302eb
commit 012f801f16
8 changed files with 658 additions and 6 deletions

View file

@ -51,9 +51,12 @@
"<%= dirs.js.src %>/plugins/jquery.pjax.js",
"<%= dirs.js.src %>/plugins/jquery.dataTables.js",
"<%= dirs.js.src %>/plugins/flavoured_checkbox.js",
"<%= dirs.js.src %>/plugins/within_viewport.js",
"<%= dirs.js.src %>/plugins/jquery.auto-grow-input.js",
"<%= dirs.js.src %>/plugins/jquery.autocomplete.js",
"<%= dirs.js.src %>/plugins/jquery.debounce.js",
"<%= dirs.js.src %>/plugins/jquery.scrollstop.js",
"<%= dirs.js.src %>/plugins/jquery.within-viewport.js",
"<%= dirs.js.node_modules %>/mark.js/dist/jquery.mark.min.js",
"<%= dirs.js.src %>/plugins/jquery.timeago.js",
"<%= dirs.js.src %>/plugins/jquery.timeago-extension.js",

View file

@ -540,10 +540,11 @@ class DiffSet(object):
})
file_chunks = patch['chunks'][1:]
for hunk in file_chunks:
for i, hunk in enumerate(file_chunks, 1):
hunkbit = self.parse_hunk(hunk, source_file, target_file)
hunkbit.source_file_path = source_file_path
hunkbit.target_file_path = target_file_path
hunkbit.index = i
filediff.hunks.append(hunkbit)
# Simulate hunk on OPS type line which doesn't really contain any diff

View file

@ -53,7 +53,7 @@ from pygments.lexers import (
get_lexer_by_name, get_lexer_for_filename, get_lexer_for_mimetype)
from pyramid.threadlocal import get_current_request
from tempita import looper
from webhelpers2.html import literal, HTML, escape
from webhelpers2.html._autolink import _auto_link_urls
from webhelpers2.html.tools import (

View file

@ -998,6 +998,21 @@ input.filediff-collapse-state {
/**** END COMMENTS ****/
.nav-chunk {
position: absolute;
right: 20px;
margin-top: -17px;
}
.nav-chunk.selected {
visibility: visible !important;
}
#diff_nav {
color: @grey3;
}
}

View file

@ -0,0 +1,91 @@
// jQuery Scrollstop Plugin v1.2.0
// https://github.com/ssorallen/jquery-scrollstop
(function (factory) {
// UMD[2] wrapper for jQuery plugins to work in AMD or in CommonJS.
//
// [2] https://github.com/umdjs/umd
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// Node/CommonJS
module.exports = factory(require('jquery'));
} else {
// Browser globals
factory(jQuery);
}
}(function ($) {
// $.event.dispatch was undocumented and was deprecated in jQuery 1.7[1]. It
// was replaced by $.event.handle in jQuery 1.9.
//
// Use the first of the available functions to support jQuery <1.8.
//
// [1] https://github.com/jquery/jquery-migrate/blob/master/src/event.js#L25
var dispatch = $.event.dispatch || $.event.handle;
var special = $.event.special,
uid1 = 'D' + (+new Date()),
uid2 = 'D' + (+new Date() + 1);
special.scrollstart = {
setup: function(data) {
var _data = $.extend({
latency: special.scrollstop.latency
}, data);
var timer,
handler = function(evt) {
var _self = this,
_args = arguments;
if (timer) {
clearTimeout(timer);
} else {
evt.type = 'scrollstart';
dispatch.apply(_self, _args);
}
timer = setTimeout(function() {
timer = null;
}, _data.latency);
};
$(this).bind('scroll', handler).data(uid1, handler);
},
teardown: function() {
$(this).unbind('scroll', $(this).data(uid1));
}
};
special.scrollstop = {
latency: 250,
setup: function(data) {
var _data = $.extend({
latency: special.scrollstop.latency
}, data);
var timer,
handler = function(evt) {
var _self = this,
_args = arguments;
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(function() {
timer = null;
evt.type = 'scrollstop';
dispatch.apply(_self, _args);
}, _data.latency);
};
$(this).bind('scroll', handler).data(uid2, handler);
},
teardown: function() {
$(this).unbind('scroll', $(this).data(uid2));
}
};
}));

View file

@ -0,0 +1,171 @@
/**
* Within Viewport jQuery Plugin
*
* @description Companion plugin for withinviewport.js - determines whether an element is completely within the browser viewport
* @author Craig Patik, http://patik.com/
* @version 2.1.2
* @date 2019-08-16
*/
(function ($) {
/**
* $.withinviewport()
* @description jQuery method
* @param {Object} [settings] optional settings
* @return {Collection} Contains all elements that were within the viewport
*/
$.fn.withinviewport = function (settings) {
var opts;
var elems;
if (typeof settings === 'string') {
settings = {
sides: settings
};
}
opts = $.extend({}, settings, {
sides: 'all'
});
elems = [];
this.each(function () {
if (withinviewport(this, opts)) {
elems.push(this);
}
});
return $(elems);
};
// Main custom selector
$.extend($.expr[':'], {
'within-viewport': function (element) {
return withinviewport(element, 'all');
}
});
/**
* Optional enhancements and shortcuts
*
* @description Uncomment or comment these pieces as they apply to your project and coding preferences
*/
// Shorthand jQuery methods
$.fn.withinviewporttop = function (settings) {
var opts;
var elems;
if (typeof settings === 'string') {
settings = {
sides: settings
};
}
opts = $.extend({}, settings, {
sides: 'top'
});
elems = [];
this.each(function () {
if (withinviewport(this, opts)) {
elems.push(this);
}
});
return $(elems);
};
$.fn.withinviewportright = function (settings) {
var opts;
var elems;
if (typeof settings === 'string') {
settings = {
sides: settings
};
}
opts = $.extend({}, settings, {
sides: 'right'
});
elems = [];
this.each(function () {
if (withinviewport(this, opts)) {
elems.push(this);
}
});
return $(elems);
};
$.fn.withinviewportbottom = function (settings) {
var opts;
var elems;
if (typeof settings === 'string') {
settings = {
sides: settings
};
}
opts = $.extend({}, settings, {
sides: 'bottom'
});
elems = [];
this.each(function () {
if (withinviewport(this, opts)) {
elems.push(this);
}
});
return $(elems);
};
$.fn.withinviewportleft = function (settings) {
var opts;
var elems;
if (typeof settings === 'string') {
settings = {
sides: settings
};
}
opts = $.extend({}, settings, {
sides: 'left'
});
elems = [];
this.each(function () {
if (withinviewport(this, opts)) {
elems.push(this);
}
});
return $(elems);
};
// Custom jQuery selectors
$.extend($.expr[':'], {
'within-viewport-top': function (element) {
return withinviewport(element, 'top');
},
'within-viewport-right': function (element) {
return withinviewport(element, 'right');
},
'within-viewport-bottom': function (element) {
return withinviewport(element, 'bottom');
},
'within-viewport-left': function (element) {
return withinviewport(element, 'left');
}
// Example custom selector:
//,
// 'within-viewport-top-left-45': function (element) {
// return withinviewport(element, {sides:'top left', top: 45, left: 45});
// }
});
}(jQuery));

View file

@ -0,0 +1,235 @@
/**
* Within Viewport
*
* @description Determines whether an element is completely within the browser viewport
* @author Craig Patik, http://patik.com/
* @version 2.1.2
* @date 2019-08-16
*/
(function (root, name, factory) {
// AMD
if (typeof define === 'function' && define.amd) {
define([], factory);
}
// Node and CommonJS-like environments
else if (typeof module !== 'undefined' && typeof exports === 'object') {
module.exports = factory();
}
// Browser global
else {
root[name] = factory();
}
}(this, 'withinviewport', function () {
var canUseWindowDimensions = typeof window !== 'undefined' && window.innerHeight !== undefined; // IE 8 and lower fail this
/**
* Determines whether an element is within the viewport
* @param {Object} elem DOM Element (required)
* @param {Object} options Optional settings
* @return {Boolean} Whether the element was completely within the viewport
*/
var withinviewport = function withinviewport(elem, options) {
var result = false;
var metadata = {};
var config = {};
var settings;
var isWithin;
var isContainerTheWindow;
var elemBoundingRect;
var containerBoundingRect;
var containerScrollTop;
var containerScrollLeft;
var scrollBarWidths = [0, 0];
var sideNamesPattern;
var sides;
var side;
var i;
// If invoked by the jQuery plugin, get the actual DOM element
if (typeof jQuery !== 'undefined' && elem instanceof jQuery) {
elem = elem.get(0);
}
if (typeof elem !== 'object' || elem.nodeType !== 1) {
throw new Error('First argument must be an element');
}
// Look for inline settings on the element
if (elem.getAttribute('data-withinviewport-settings') && window.JSON) {
metadata = JSON.parse(elem.getAttribute('data-withinviewport-settings'));
}
// Settings argument may be a simple string (`top`, `right`, etc)
if (typeof options === 'string') {
settings = {
sides: options
};
} else {
settings = options || {};
}
// Build configuration from defaults and user-provided settings and metadata
config.container = settings.container || metadata.container || withinviewport.defaults.container || window;
config.sides = settings.sides || metadata.sides || withinviewport.defaults.sides || 'all';
config.top = settings.top || metadata.top || withinviewport.defaults.top || 0;
config.right = settings.right || metadata.right || withinviewport.defaults.right || 0;
config.bottom = settings.bottom || metadata.bottom || withinviewport.defaults.bottom || 0;
config.left = settings.left || metadata.left || withinviewport.defaults.left || 0;
// Extract the DOM node from a jQuery collection
if (typeof jQuery !== 'undefined' && config.container instanceof jQuery) {
config.container = config.container.get(0);
}
// Use the window as the container if the user specified the body or a non-element
if (config.container === document.body || config.container.nodeType !== 1) {
config.container = window;
}
isContainerTheWindow = (config.container === window);
// Element testing methods
isWithin = {
// Element is below the top edge of the viewport
top: function _isWithin_top() {
if (isContainerTheWindow) {
return (elemBoundingRect.top >= config.top);
} else {
return (elemBoundingRect.top >= containerScrollTop - (containerScrollTop - containerBoundingRect.top) + config.top);
}
},
// Element is to the left of the right edge of the viewport
right: function _isWithin_right() {
// Note that `elemBoundingRect.right` is the distance from the *left* of the viewport to the element's far right edge
if (isContainerTheWindow) {
return (elemBoundingRect.right <= (containerBoundingRect.right + containerScrollLeft) - config.right);
} else {
return (elemBoundingRect.right <= containerBoundingRect.right - scrollBarWidths[0] - config.right);
}
},
// Element is above the bottom edge of the viewport
bottom: function _isWithin_bottom() {
var containerHeight = 0;
if (isContainerTheWindow) {
if (canUseWindowDimensions) {
containerHeight = config.container.innerHeight;
} else if (document && document.documentElement) {
containerHeight = document.documentElement.clientHeight;
}
} else {
containerHeight = containerBoundingRect.bottom;
}
// Note that `elemBoundingRect.bottom` is the distance from the *top* of the viewport to the element's bottom edge
return (elemBoundingRect.bottom <= containerHeight - scrollBarWidths[1] - config.bottom);
},
// Element is to the right of the left edge of the viewport
left: function _isWithin_left() {
if (isContainerTheWindow) {
return (elemBoundingRect.left >= config.left);
} else {
return (elemBoundingRect.left >= containerScrollLeft - (containerScrollLeft - containerBoundingRect.left) + config.left);
}
},
// Element is within all four boundaries
all: function _isWithin_all() {
// Test each boundary in order of efficiency and likeliness to be false. This way we can avoid running all four functions on most elements.
// 1. Top: Quickest to calculate + most likely to be false
// 2. Bottom: Note quite as quick to calculate, but also very likely to be false
// 3-4. Left and right are both equally unlikely to be false since most sites only scroll vertically, but left is faster to calculate
return (isWithin.top() && isWithin.bottom() && isWithin.left() && isWithin.right());
}
};
// Get the element's bounding rectangle with respect to the viewport
elemBoundingRect = elem.getBoundingClientRect();
// Get viewport dimensions and offsets
if (isContainerTheWindow) {
containerBoundingRect = document.documentElement.getBoundingClientRect();
containerScrollTop = document.body.scrollTop;
containerScrollLeft = window.scrollX || document.body.scrollLeft;
} else {
containerBoundingRect = config.container.getBoundingClientRect();
containerScrollTop = config.container.scrollTop;
containerScrollLeft = config.container.scrollLeft;
}
// Don't count the space consumed by scrollbars
if (containerScrollLeft) {
scrollBarWidths[0] = 18;
}
if (containerScrollTop) {
scrollBarWidths[1] = 16;
}
// Test the element against each side of the viewport that was requested
sideNamesPattern = /^top$|^right$|^bottom$|^left$|^all$/;
// Loop through all of the sides
sides = config.sides.split(' ');
i = sides.length;
while (i--) {
side = sides[i].toLowerCase();
if (sideNamesPattern.test(side)) {
if (isWithin[side]()) {
result = true;
} else {
result = false;
// Quit as soon as the first failure is found
break;
}
}
}
return result;
};
// Default settings
withinviewport.prototype.defaults = {
container: typeof document !== 'undefined' ? document.body : {},
sides: 'all',
top: 0,
right: 0,
bottom: 0,
left: 0
};
withinviewport.defaults = withinviewport.prototype.defaults;
/**
* Optional enhancements and shortcuts
*
* @description Uncomment or comment these pieces as they apply to your project and coding preferences
*/
// Shortcut methods for each side of the viewport
// Example: `withinviewport.top(elem)` is the same as `withinviewport(elem, 'top')`
withinviewport.prototype.top = function _withinviewport_top(element) {
return withinviewport(element, 'top');
};
withinviewport.prototype.right = function _withinviewport_right(element) {
return withinviewport(element, 'right');
};
withinviewport.prototype.bottom = function _withinviewport_bottom(element) {
return withinviewport(element, 'bottom');
};
withinviewport.prototype.left = function _withinviewport_left(element) {
return withinviewport(element, 'left');
};
return withinviewport;
}));

View file

@ -314,6 +314,7 @@ return '%s_%s_%i' % (h.md5_safe(commit+filename), type, line)
${hunk.section_header}
</td>
</tr>
${render_hunk_lines(filediff, c.user_session_attrs["diffmode"], hunk, use_comments=use_comments, inline_comments=inline_comments, active_pattern_entries=active_pattern_entries)}
% endfor
@ -657,21 +658,28 @@ def get_comments_for(diff_type, comments, filename, line_version, line_number):
%>
<%def name="render_hunk_lines_sideside(filediff, hunk, use_comments=False, inline_comments=None, active_pattern_entries=None)">
%for i, line in enumerate(hunk.sideside):
<% chunk_count = 1 %>
%for loop_obj, item in h.looper(hunk.sideside):
<%
line = item
i = loop_obj.index
prev_line = loop_obj.previous
old_line_anchor, new_line_anchor = None, None
if line.original.lineno:
old_line_anchor = diff_line_anchor(filediff.raw_id, hunk.source_file_path, line.original.lineno, 'o')
if line.modified.lineno:
new_line_anchor = diff_line_anchor(filediff.raw_id, hunk.target_file_path, line.modified.lineno, 'n')
line_action = line.modified.action or line.original.action
prev_line_action = prev_line and (prev_line.modified.action or prev_line.original.action)
%>
<tr class="cb-line">
<td class="cb-data ${action_class(line.original.action)}"
data-line-no="${line.original.lineno}"
>
<div>
<% line_old_comments = None %>
%if line.original.get_comment_args:
@ -685,7 +693,6 @@ def get_comments_for(diff_type, comments, filename, line_version, line_number):
<i class="tooltip icon-comment" title="${_('comments: {}. Click to toggle them.').format(len(line_old_comments))}" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
% endif
%endif
</div>
</td>
<td class="cb-lineno ${action_class(line.original.action)}"
data-line-no="${line.original.lineno}"
@ -751,6 +758,12 @@ def get_comments_for(diff_type, comments, filename, line_version, line_number):
%if use_comments and line.modified.lineno and line_new_comments:
${inline_comments_container(line_new_comments, active_pattern_entries=active_pattern_entries)}
%endif
% if line_action in ['+', '-'] and prev_line_action not in ['+', '-']:
<div class="nav-chunk" style="visibility: hidden">
<i class="icon-eye" title="viewing diff hunk-${hunk.index}-${chunk_count}"></i>
</div>
<% chunk_count +=1 %>
% endif
</td>
</tr>
%endfor
@ -903,12 +916,21 @@ def get_comments_for(diff_type, comments, filename, line_version, line_number):
</div>
</div>
</div>
<div class="fpath-placeholder">
<div class="fpath-placeholder pull-left">
<i class="icon-file-text"></i>
<strong class="fpath-placeholder-text">
Context file:
</strong>
</div>
<div class="pull-right noselect">
<span id="diff_nav">Loading diff...:</span>
<span class="cursor-pointer" onclick="scrollToPrevChunk(); return false">
<i class="icon-angle-up"></i>
</span>
<span class="cursor-pointer" onclick="scrollToNextChunk(); return false">
<i class="icon-angle-down"></i>
</span>
</div>
<div class="sidebar_inner_shadow"></div>
</div>
</div>
@ -1031,10 +1053,84 @@ def get_comments_for(diff_type, comments, filename, line_version, line_number):
e.preventDefault();
});
getCurrentChunk = function () {
var chunksAll = $('.nav-chunk').filter(function () {
return $(this).parents('.filediff').prev().get(0).checked !== true
})
var chunkSelected = $('.nav-chunk.selected');
var initial = false;
if (chunkSelected.length === 0) {
// no initial chunk selected, we pick first
chunkSelected = $(chunksAll.get(0));
var initial = true;
}
return {
'all': chunksAll,
'selected': chunkSelected,
'initial': initial,
}
}
animateDiffNavText = function () {
var $diffNav = $('#diff_nav')
var callback = function () {
$diffNav.animate({'opacity': 1.00}, 200)
};
$diffNav.animate({'opacity': 0.15}, 200, callback);
}
scrollToChunk = function (moveBy) {
var chunk = getCurrentChunk();
var all = chunk.all
var selected = chunk.selected
var curPos = all.index(selected);
var newPos = curPos;
if (!chunk.initial) {
var newPos = curPos + moveBy;
}
var curElem = all.get(newPos);
if (curElem === undefined) {
// end or back
$('#diff_nav').html('No next diff element.')
animateDiffNavText()
return
} else if (newPos < 0) {
$('#diff_nav').html('No previous diff element.')
animateDiffNavText()
return
} else {
$('#diff_nav').html('Diff navigation:')
}
curElem = $(curElem)
var offset = 100;
$(window).scrollTop(curElem.position().top - offset);
//clear selection
all.removeClass('selected')
curElem.addClass('selected')
}
scrollToPrevChunk = function () {
scrollToChunk(-1)
}
scrollToNextChunk = function () {
scrollToChunk(1)
}
</script>
% endif
<script type="text/javascript">
$('#diff_nav').html('loading diff...') // wait until whole page is loaded
$(document).ready(function () {
var contextPrefix = _gettext('Context file: ');
@ -1213,6 +1309,46 @@ def get_comments_for(diff_type, comments, filename, line_version, line_number):
$('.toggle-wide-diff').addClass('btn-active');
updateSticky();
}
// DIFF NAV //
// element to detect scroll direction of
var $window = $(window);
// initialize last scroll position
var lastScrollY = $window.scrollTop();
$window.on('resize scrollstop', {latency: 350}, function () {
var visibleChunks = $('.nav-chunk').withinviewport({top: 75});
// get current scroll position
var currentScrollY = $window.scrollTop();
// determine current scroll direction
if (currentScrollY > lastScrollY) {
var y = 'down'
} else if (currentScrollY !== lastScrollY) {
var y = 'up';
}
var pos = -1; // by default we use last element in viewport
if (y === 'down') {
pos = -1;
} else if (y === 'up') {
pos = 0;
}
if (visibleChunks.length > 0) {
$('.nav-chunk').removeClass('selected');
$(visibleChunks.get(pos)).addClass('selected');
}
// update last scroll position to current position
lastScrollY = currentScrollY;
});
$('#diff_nav').html('Diff navigation:')
});
</script>