Patchwork [V6] hgweb: expose a followlines UI in filerevision view

login
register
mail settings
Submitter Denis Laxalde
Date March 30, 2017, 8:29 a.m.
Message ID <1c360ab5640bc326dd6f.1490862547@sh77.tls.logilab.fr>
Download mbox | patch
Permalink /patch/19841/
State Superseded
Headers show

Comments

Denis Laxalde - March 30, 2017, 8:29 a.m.
# HG changeset patch
# User Denis Laxalde <denis.laxalde@logilab.fr>
# Date 1490819176 -7200
#      Wed Mar 29 22:26:16 2017 +0200
# Node ID 1c360ab5640bc326dd6ff70bdee78e4da006dcef
# Parent  e540846c67e0f838bcdb1db567a57df28d92491c
# Available At http://hg.logilab.org/users/dlaxalde/hg
#              hg pull http://hg.logilab.org/users/dlaxalde/hg -r 1c360ab5640b
# EXP-Topic linerange-log/hgweb-filelog
hgweb: expose a followlines UI in filerevision view

In filerevision view (/file/<rev>/<fname>) we add some event listeners on
mouse clicks of <span> elements in the <pre class="sourcelines"> block.
Those listeners will capture a range of lines selected between two mouse
clicks and a box inviting to follow the history of selected lines will then
show up. Selected lines (i.e. the block of lines) get a CSS class which make
them highlighted. Selection can be cancelled (and restarted) by either
clicking on the cancel ("x") button in the invite box or clicking on any other
source line. Also clicking twice on the same line will abort the selection and
reset event listeners to restart the process.

As a first step, this action is only advertised by the "cursor: cell" CSS rule
on source lines elements as any other mechanisms would make the code
significantly more complicated. This might be improved later.

All JavaScript code lives in a new "linerangelog.js" file, sourced in
filerevision template (only in "paper" style for now).
Gregory Szorc - March 31, 2017, 4:03 a.m.
On Thu, Mar 30, 2017 at 1:29 AM, Denis Laxalde <denis@laxalde.org> wrote:

> # HG changeset patch
> # User Denis Laxalde <denis.laxalde@logilab.fr>
> # Date 1490819176 -7200
> #      Wed Mar 29 22:26:16 2017 +0200
> # Node ID 1c360ab5640bc326dd6ff70bdee78e4da006dcef
> # Parent  e540846c67e0f838bcdb1db567a57df28d92491c
> # Available At http://hg.logilab.org/users/dlaxalde/hg
> #              hg pull http://hg.logilab.org/users/dlaxalde/hg -r
> 1c360ab5640b
> # EXP-Topic linerange-log/hgweb-filelog
> hgweb: expose a followlines UI in filerevision view
>
> In filerevision view (/file/<rev>/<fname>) we add some event listeners on
> mouse clicks of <span> elements in the <pre class="sourcelines"> block.
> Those listeners will capture a range of lines selected between two mouse
> clicks and a box inviting to follow the history of selected lines will then
> show up. Selected lines (i.e. the block of lines) get a CSS class which
> make
> them highlighted. Selection can be cancelled (and restarted) by either
> clicking on the cancel ("x") button in the invite box or clicking on any
> other
> source line. Also clicking twice on the same line will abort the selection
> and
> reset event listeners to restart the process.
>
> As a first step, this action is only advertised by the "cursor: cell" CSS
> rule
> on source lines elements as any other mechanisms would make the code
> significantly more complicated. This might be improved later.
>
> All JavaScript code lives in a new "linerangelog.js" file, sourced in
> filerevision template (only in "paper" style for now).
>

This patch is great! As far as functionality goes, I think it is almost
good enough to queue. There are still some usability improvements (such as
an aid to let users know that clicking a line will allow them to follow
that line). But those features can be implemented as follow-ups IMO.


>
> diff --git a/contrib/wix/templates.wxs b/contrib/wix/templates.wxs
> --- a/contrib/wix/templates.wxs
> +++ b/contrib/wix/templates.wxs
> @@ -225,6 +225,7 @@
>              <File Id="static.coal.file.png"      Name="coal-file.png" />
>              <File Id="static.coal.folder.png"    Name="coal-folder.png" />
>              <File Id="static.excanvas.js"        Name="excanvas.js" />
> +            <File Id="static.linerangelog.js"    Name="linerangelog.js" />
>              <File Id="static.mercurial.js"       Name="mercurial.js" />
>              <File Id="static.hgicon.png"         Name="hgicon.png" />
>              <File Id="static.hglogo.png"         Name="hglogo.png" />
> diff --git a/mercurial/templates/paper/filerevision.tmpl
> b/mercurial/templates/paper/filerevision.tmpl
> --- a/mercurial/templates/paper/filerevision.tmpl
> +++ b/mercurial/templates/paper/filerevision.tmpl
> @@ -71,8 +71,11 @@
>  <div class="overflow">
>  <div class="sourcefirst linewraptoggle">line wrap: <a
> class="linewraplink" href="javascript:toggleLinewrap()">on</a></div>
>  <div class="sourcefirst"> line source</div>
> -<pre class="sourcelines stripes4 wrap bottomline">{text%fileline}</pre>
> +<pre class="sourcelines stripes4 wrap bottomline"
> data-logurl="{url|urlescape}log/{symrev}/{file|urlescape}"
> >{text%fileline}</pre>
>  </div>
> +
> +<script type="text/javascript" src="{staticurl|urlescape}
> linerangelog.js"></script>
> +
>  </div>
>  </div>
>
> diff --git a/mercurial/templates/static/linerangelog.js
> b/mercurial/templates/static/linerangelog.js
> new file mode 100644
> --- /dev/null
> +++ b/mercurial/templates/static/linerangelog.js
> @@ -0,0 +1,137 @@
> +// Copyright 2017 Logilab SA <contact@logilab.fr>
> +//
> +// This software may be used and distributed according to the terms
> +// of the GNU General Public License, incorporated herein by reference.
>

This license header is slightly different from what is used elsewhere. In
other files, we use "version 2 or any later version." I see this file's
version is used by .c files and a few other random places. But since it is
in the minority, let's stick to the "version 2 or any later version"
language, as seen in e.g. mercurial/__init__.py.

+
> +//** Install event listeners for line block selection and followlines
> action */
> +function installLineSelect() {
> +    var sourcelines = document.getElementsByClassName('sourcelines')[0];
> +    if (typeof sourcelines === 'undefined') {
> +        return;
> +    }
> +    // URL to complement with "linerange" query parameter
> +    var targetUri = sourcelines.dataset.logurl;
> +    if (typeof targetUri === 'undefined') {
> +        return;
> +    }
> +
> +    var lineSelectedCSSClass = 'followlines-selected';
> +
> +    //** add CSS class on <span> element in `from`-`to` line range */
> +    function addSelectedCSSClass(from, to) {
> +        var spans = sourcelines.getElementsByTagName('span');
> +        for (var i = from; i <= to; i++) {
> +            spans.item(i).classList.add(lineSelectedCSSClass);
> +        }
> +    }
> +
> +    //** remove CSS class from previously selected lines */
> +    function removeSelectedCSSClass() {
> +        var nodes = sourcelines.getElementsByClassName(
> +            lineSelectedCSSClass);
> +        while (nodes.length) {
> +            nodes[0].classList.remove(lineSelectedCSSClass);
> +        }
> +    }
> +
> +    // add event listener to the whole <pre class="sourcelines"> block to
> have
> +    // it available in all children <span>
> +    sourcelines.addEventListener('click', lineSelectStart);
>

I know this works, but could you please move this until after the
definition of lineSelectStart() because it feels weird to see the reference
to a symbol before it is declared.


> +
> +    //** event handler for "click" on the first line of a block */
> +    function lineSelectStart(e) {
> +        var startElement = e.target;
> +        if (startElement.tagName !== 'SPAN') {
> +            // not a <span> (maybe <a>): abort, keeping event listener
> +            // registered for other click with <span> target
> +            return;
>

I wonder what happens when Pygments is installed and the HTML is no longer
as simple as we expect. It feels like we should traverse
startElement.parentElement until we find an element with
.parentElement.isSameNode(sourcelines) that is also a <span>.
Alternatively, on startup we could iterate sourcelines.children and add a
CSS class (I don't think we should add the class to the HTML because it
would needlessly bloat the HTML).


> +        }
> +        var startId = parseInt(startElement.id.slice(1));
> +        startElement.classList.add(lineSelectedCSSClass); // CSS
> +
> +        // remove this event listener
> +        sourcelines.removeEventListener('click', lineSelectStart);
> +
> +        //** event handler for "click" on the last line of the block */
> +        function lineSelectEnd(e) {
> +            var endElement = e.target;
> +            if (endElement.tagName !== 'SPAN') {
> +                // not a <span> (maybe <a>): abort, keeping event listener
> +                // registered for other click with <span> target
> +                return;
>

Same concern with not being robust enough.


> +            }
> +
> +            // remove this event listener
> +            sourcelines.removeEventListener('click', lineSelectEnd);
> +
> +            // compute line range (startId, endId)
> +            var endId = parseInt(endElement.id.slice(1));
> +            if (endId == startId) {
> +                // clicked twice the same line, cancel and reset initial
> state
> +                // (CSS and event listener for selection start)
> +                removeSelectedCSSClass();
> +                sourcelines.addEventListener('click', lineSelectStart);
> +                return;
> +            }
> +            var inviteElement = endElement;
> +            if (endId < startId) {
> +                var tmp = endId;
> +                endId = startId;
> +                startId = tmp;
> +                inviteElement = startElement;
> +            }
> +
> +            addSelectedCSSClass(startId - 1, endId -1);  // CSS
> +
> +            // append the <div id="followlines"> element to last line of
> the
> +            // selection block
> +            var divAndButton = followlinesBox(targetUri, startId, endId);
> +            var div = divAndButton[0],
> +                button = divAndButton[1];
> +            inviteElement.appendChild(div);
> +
> +            //** event handler for cancelling selection */
> +            function cancel() {
> +                // remove invite box
> +                div.parentNode.removeChild(div);
> +                // restore initial event listeners
> +                sourcelines.addEventListener('click', lineSelectStart);
> +                sourcelines.removeEventListener('click', cancel);
> +                // remove styles on selected lines
> +                removeSelectedCSSClass();
> +            }
> +
> +            // bind cancel event to click on <button>
> +            button.addEventListener('click', cancel);
> +            // as well as on an click on any source line
> +            sourcelines.addEventListener('click', cancel);
> +        }
> +
> +        sourcelines.addEventListener('click', lineSelectEnd);
> +
> +    }
> +
> +}
> +
> +//** return a <div id="followlines"> and inner cancel <button> elements */
> +function followlinesBox(targetUri, startId, endId) {
> +    var div = document.createElement('div');
> +    div.id = 'followlines';
> +    var aDiv = document.createElement('div');
> +    aDiv.classList.add('followlines-link');
> +    var a = document.createElement('a');
> +    var url = targetUri + '?patch=&linerange=' + startId + ':' + endId;
> +    a.setAttribute('href', url);
> +    a.textContent = 'follow lines ' + startId + ':' + endId;
> +    aDiv.appendChild(a);
> +    var buttonDiv = document.createElement('div');
> +    buttonDiv.classList.add('followlines-cancel');
> +    var button = document.createElement('button');
> +    button.textContent = 'x';
> +    buttonDiv.appendChild(button);
> +    div.appendChild(buttonDiv);
> +    div.appendChild(aDiv);
> +    return [div, button];
>

This code is somewhat difficult to read. Could you please add some blank
lines to separate the discrete elements/operations?


> +}
> +
> +document.addEventListener('DOMContentLoaded', installLineSelect, false);
> diff --git a/mercurial/templates/static/style-paper.css
> b/mercurial/templates/static/style-paper.css
> --- a/mercurial/templates/static/style-paper.css
> +++ b/mercurial/templates/static/style-paper.css
> @@ -280,6 +280,46 @@ td.annotate:hover div.annotate-info { di
>    background-color: #bfdfff;
>  }
>
> +div.overflow pre.sourcelines > span:hover {
> +  cursor: cell;
> +}
> +
> +pre.sourcelines > span.followlines-selected {
> +  background-color: #99C7E9;
> +}
> +
> +div#followlines {
> +  background-color: #B7B7B7;
> +  border: 1px solid #CCC;
> +  border-radius: 5px;
> +  padding: 4px;
> +  position: absolute;
> +}
> +
> +div.followlines-cancel {
> +  text-align: right;
> +}
> +
> +div.followlines-cancel > button {
> +  line-height: 80%;
> +  padding: 0;
> +  border: 0;
> +  border-radius: 2px;
> +  background-color: inherit;
> +  font-weight: bold;
> +}
> +
> +div.followlines-cancel > button:hover {
> +  color: #FFFFFF;
> +  background-color: #CF1F1F;
> +}
> +
> +div.followlines-link {
> +  margin: 2px;
> +  margin-top: 4px;
> +  font-family: sans-serif;
> +}
> +
>  .sourcelines > a {
>      display: inline-block;
>      position: absolute;
> diff --git a/tests/test-hgweb-commands.t b/tests/test-hgweb-commands.t
> --- a/tests/test-hgweb-commands.t
> +++ b/tests/test-hgweb-commands.t
> @@ -1343,9 +1343,12 @@ File-related
>    <div class="overflow">
>    <div class="sourcefirst linewraptoggle">line wrap: <a
> class="linewraplink" href="javascript:toggleLinewrap()">on</a></div>
>    <div class="sourcefirst"> line source</div>
> -  <pre class="sourcelines stripes4 wrap bottomline">
> +  <pre class="sourcelines stripes4 wrap bottomline"
> data-logurl="/log/1/foo">
>    <span id="l1">foo</span><a href="#l1"></a></pre>
>    </div>
> +
> +  <script type="text/javascript" src="/static/linerangelog.js"></script>
> +
>    </div>
>    </div>
>
> @@ -1468,9 +1471,12 @@ File-related
>    <div class="overflow">
>    <div class="sourcefirst linewraptoggle">line wrap: <a
> class="linewraplink" href="javascript:toggleLinewrap()">on</a></div>
>    <div class="sourcefirst"> line source</div>
> -  <pre class="sourcelines stripes4 wrap bottomline">
> +  <pre class="sourcelines stripes4 wrap bottomline"
> data-logurl="/log/2/foo">
>    <span id="l1">another</span><a href="#l1"></a></pre>
>    </div>
> +
> +  <script type="text/javascript" src="/static/linerangelog.js"></script>
> +
>    </div>
>    </div>
>
> _______________________________________________
> Mercurial-devel mailing list
> Mercurial-devel@mercurial-scm.org
> https://www.mercurial-scm.org/mailman/listinfo/mercurial-devel
>

Patch

diff --git a/contrib/wix/templates.wxs b/contrib/wix/templates.wxs
--- a/contrib/wix/templates.wxs
+++ b/contrib/wix/templates.wxs
@@ -225,6 +225,7 @@ 
             <File Id="static.coal.file.png"      Name="coal-file.png" />
             <File Id="static.coal.folder.png"    Name="coal-folder.png" />
             <File Id="static.excanvas.js"        Name="excanvas.js" />
+            <File Id="static.linerangelog.js"    Name="linerangelog.js" />
             <File Id="static.mercurial.js"       Name="mercurial.js" />
             <File Id="static.hgicon.png"         Name="hgicon.png" />
             <File Id="static.hglogo.png"         Name="hglogo.png" />
diff --git a/mercurial/templates/paper/filerevision.tmpl b/mercurial/templates/paper/filerevision.tmpl
--- a/mercurial/templates/paper/filerevision.tmpl
+++ b/mercurial/templates/paper/filerevision.tmpl
@@ -71,8 +71,11 @@ 
 <div class="overflow">
 <div class="sourcefirst linewraptoggle">line wrap: <a class="linewraplink" href="javascript:toggleLinewrap()">on</a></div>
 <div class="sourcefirst"> line source</div>
-<pre class="sourcelines stripes4 wrap bottomline">{text%fileline}</pre>
+<pre class="sourcelines stripes4 wrap bottomline" data-logurl="{url|urlescape}log/{symrev}/{file|urlescape}">{text%fileline}</pre>
 </div>
+
+<script type="text/javascript" src="{staticurl|urlescape}linerangelog.js"></script>
+
 </div>
 </div>
 
diff --git a/mercurial/templates/static/linerangelog.js b/mercurial/templates/static/linerangelog.js
new file mode 100644
--- /dev/null
+++ b/mercurial/templates/static/linerangelog.js
@@ -0,0 +1,137 @@ 
+// Copyright 2017 Logilab SA <contact@logilab.fr>
+//
+// This software may be used and distributed according to the terms
+// of the GNU General Public License, incorporated herein by reference.
+
+//** Install event listeners for line block selection and followlines action */
+function installLineSelect() {
+    var sourcelines = document.getElementsByClassName('sourcelines')[0];
+    if (typeof sourcelines === 'undefined') {
+        return;
+    }
+    // URL to complement with "linerange" query parameter
+    var targetUri = sourcelines.dataset.logurl;
+    if (typeof targetUri === 'undefined') {
+        return;
+    }
+
+    var lineSelectedCSSClass = 'followlines-selected';
+
+    //** add CSS class on <span> element in `from`-`to` line range */
+    function addSelectedCSSClass(from, to) {
+        var spans = sourcelines.getElementsByTagName('span');
+        for (var i = from; i <= to; i++) {
+            spans.item(i).classList.add(lineSelectedCSSClass);
+        }
+    }
+
+    //** remove CSS class from previously selected lines */
+    function removeSelectedCSSClass() {
+        var nodes = sourcelines.getElementsByClassName(
+            lineSelectedCSSClass);
+        while (nodes.length) {
+            nodes[0].classList.remove(lineSelectedCSSClass);
+        }
+    }
+
+    // add event listener to the whole <pre class="sourcelines"> block to have
+    // it available in all children <span>
+    sourcelines.addEventListener('click', lineSelectStart);
+
+    //** event handler for "click" on the first line of a block */
+    function lineSelectStart(e) {
+        var startElement = e.target;
+        if (startElement.tagName !== 'SPAN') {
+            // not a <span> (maybe <a>): abort, keeping event listener
+            // registered for other click with <span> target
+            return;
+        }
+        var startId = parseInt(startElement.id.slice(1));
+        startElement.classList.add(lineSelectedCSSClass); // CSS
+
+        // remove this event listener
+        sourcelines.removeEventListener('click', lineSelectStart);
+
+        //** event handler for "click" on the last line of the block */
+        function lineSelectEnd(e) {
+            var endElement = e.target;
+            if (endElement.tagName !== 'SPAN') {
+                // not a <span> (maybe <a>): abort, keeping event listener
+                // registered for other click with <span> target
+                return;
+            }
+
+            // remove this event listener
+            sourcelines.removeEventListener('click', lineSelectEnd);
+
+            // compute line range (startId, endId)
+            var endId = parseInt(endElement.id.slice(1));
+            if (endId == startId) {
+                // clicked twice the same line, cancel and reset initial state
+                // (CSS and event listener for selection start)
+                removeSelectedCSSClass();
+                sourcelines.addEventListener('click', lineSelectStart);
+                return;
+            }
+            var inviteElement = endElement;
+            if (endId < startId) {
+                var tmp = endId;
+                endId = startId;
+                startId = tmp;
+                inviteElement = startElement;
+            }
+
+            addSelectedCSSClass(startId - 1, endId -1);  // CSS
+
+            // append the <div id="followlines"> element to last line of the
+            // selection block
+            var divAndButton = followlinesBox(targetUri, startId, endId);
+            var div = divAndButton[0],
+                button = divAndButton[1];
+            inviteElement.appendChild(div);
+
+            //** event handler for cancelling selection */
+            function cancel() {
+                // remove invite box
+                div.parentNode.removeChild(div);
+                // restore initial event listeners
+                sourcelines.addEventListener('click', lineSelectStart);
+                sourcelines.removeEventListener('click', cancel);
+                // remove styles on selected lines
+                removeSelectedCSSClass();
+            }
+
+            // bind cancel event to click on <button>
+            button.addEventListener('click', cancel);
+            // as well as on an click on any source line
+            sourcelines.addEventListener('click', cancel);
+        }
+
+        sourcelines.addEventListener('click', lineSelectEnd);
+
+    }
+
+}
+
+//** return a <div id="followlines"> and inner cancel <button> elements */
+function followlinesBox(targetUri, startId, endId) {
+    var div = document.createElement('div');
+    div.id = 'followlines';
+    var aDiv = document.createElement('div');
+    aDiv.classList.add('followlines-link');
+    var a = document.createElement('a');
+    var url = targetUri + '?patch=&linerange=' + startId + ':' + endId;
+    a.setAttribute('href', url);
+    a.textContent = 'follow lines ' + startId + ':' + endId;
+    aDiv.appendChild(a);
+    var buttonDiv = document.createElement('div');
+    buttonDiv.classList.add('followlines-cancel');
+    var button = document.createElement('button');
+    button.textContent = 'x';
+    buttonDiv.appendChild(button);
+    div.appendChild(buttonDiv);
+    div.appendChild(aDiv);
+    return [div, button];
+}
+
+document.addEventListener('DOMContentLoaded', installLineSelect, false);
diff --git a/mercurial/templates/static/style-paper.css b/mercurial/templates/static/style-paper.css
--- a/mercurial/templates/static/style-paper.css
+++ b/mercurial/templates/static/style-paper.css
@@ -280,6 +280,46 @@  td.annotate:hover div.annotate-info { di
   background-color: #bfdfff;
 }
 
+div.overflow pre.sourcelines > span:hover {
+  cursor: cell;
+}
+
+pre.sourcelines > span.followlines-selected {
+  background-color: #99C7E9;
+}
+
+div#followlines {
+  background-color: #B7B7B7;
+  border: 1px solid #CCC;
+  border-radius: 5px;
+  padding: 4px;
+  position: absolute;
+}
+
+div.followlines-cancel {
+  text-align: right;
+}
+
+div.followlines-cancel > button {
+  line-height: 80%;
+  padding: 0;
+  border: 0;
+  border-radius: 2px;
+  background-color: inherit;
+  font-weight: bold;
+}
+
+div.followlines-cancel > button:hover {
+  color: #FFFFFF;
+  background-color: #CF1F1F;
+}
+
+div.followlines-link {
+  margin: 2px;
+  margin-top: 4px;
+  font-family: sans-serif;
+}
+
 .sourcelines > a {
     display: inline-block;
     position: absolute;
diff --git a/tests/test-hgweb-commands.t b/tests/test-hgweb-commands.t
--- a/tests/test-hgweb-commands.t
+++ b/tests/test-hgweb-commands.t
@@ -1343,9 +1343,12 @@  File-related
   <div class="overflow">
   <div class="sourcefirst linewraptoggle">line wrap: <a class="linewraplink" href="javascript:toggleLinewrap()">on</a></div>
   <div class="sourcefirst"> line source</div>
-  <pre class="sourcelines stripes4 wrap bottomline">
+  <pre class="sourcelines stripes4 wrap bottomline" data-logurl="/log/1/foo">
   <span id="l1">foo</span><a href="#l1"></a></pre>
   </div>
+  
+  <script type="text/javascript" src="/static/linerangelog.js"></script>
+  
   </div>
   </div>
   
@@ -1468,9 +1471,12 @@  File-related
   <div class="overflow">
   <div class="sourcefirst linewraptoggle">line wrap: <a class="linewraplink" href="javascript:toggleLinewrap()">on</a></div>
   <div class="sourcefirst"> line source</div>
-  <pre class="sourcelines stripes4 wrap bottomline">
+  <pre class="sourcelines stripes4 wrap bottomline" data-logurl="/log/2/foo">
   <span id="l1">another</span><a href="#l1"></a></pre>
   </div>
+  
+  <script type="text/javascript" src="/static/linerangelog.js"></script>
+  
   </div>
   </div>