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

login
register
mail settings
Submitter Denis Laxalde
Date March 31, 2017, 11:47 a.m.
Message ID <be7965e3afe82be35d25.1490960857@sh77.tls.logilab.fr>
Download mbox | patch
Permalink /patch/19871/
State Accepted
Headers show

Comments

Denis Laxalde - March 31, 2017, 11:47 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 be7965e3afe82be35d258a2cff12b389a857ef88
# Parent  dea2a17cbfd00bf08ee87b3e44b1c71499189f89
# Available At http://hg.logilab.org/users/dlaxalde/hg
#              hg pull http://hg.logilab.org/users/dlaxalde/hg -r be7965e3afe8
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 - April 1, 2017, 1:53 a.m.
On Fri, Mar 31, 2017 at 4:47 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 be7965e3afe82be35d258a2cff12b389a857ef88
> # Parent  dea2a17cbfd00bf08ee87b3e44b1c71499189f89
> # Available At http://hg.logilab.org/users/dlaxalde/hg
> #              hg pull http://hg.logilab.org/users/dlaxalde/hg -r
> be7965e3afe8
> hgweb: expose a followlines UI in filerevision view
>

This looks good to me and I think it can be queued. There is obviously some
follow-up work:

* Add support for gitweb (and other styles if we care)
* Add floating labels (or similar) to help draw attention to the feature
* Add support on the blame page (and anywhere else it makes sense)
* CSS tweaking (if others want to)

But this can be deferred until after landing. Perfect is the enemy of good.

This is an awesome feature and I can wait to use it!

Before I forget, I think someone should record a demo video or an animated
GIF of this to put in the 4.2 release notes. AFAIK no other VCS has this
feature and I think we could turn some heads by calling attention to it.


>
> 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).
>
> 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,163 @@
> +// linerangelog.js - JavaScript utilities for followlines UI
> +//
> +// Copyright 2017 Logilab SA <contact@logilab.fr>
> +//
> +// This software may be used and distributed according to the terms of the
> +// GNU General Public License version 2 or any later version.
> +
> +//** 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;
> +    }
> +
> +    // retrieve all direct <span> children of <pre class="sourcelines">
> +    var spans = Array.prototype.filter.call(
> +        sourcelines.children,
> +        function(x) { return x.tagName === 'SPAN' });
> +
> +    var lineSelectedCSSClass = 'followlines-selected';
> +
> +    //** add CSS class on <span> element in `from`-`to` line range */
> +    function addSelectedCSSClass(from, to) {
> +        for (var i = from; i <= to; i++) {
> +            spans[i].classList.add(lineSelectedCSSClass);
> +        }
> +    }
> +
> +    //** remove CSS class from previously selected lines */
> +    function removeSelectedCSSClass() {
> +        var elements = sourcelines.getElementsByClassName(
> +            lineSelectedCSSClass);
> +        while (elements.length) {
> +            elements[0].classList.remove(lineSelectedCSSClass);
> +        }
> +    }
> +
> +    // ** return the <span> element parent of `element` */
> +    function findParentSpan(element) {
> +        var parent = element.parentElement;
> +        if (parent === null) {
> +            return null;
> +        }
> +        if (element.tagName == 'SPAN' && parent.isSameNode(sourcelines))
> {
> +            return element;
> +        }
> +        return findParentSpan(parent);
> +    }
> +
> +    //** event handler for "click" on the first line of a block */
> +    function lineSelectStart(e) {
> +        var startElement = findParentSpan(e.target);
> +        if (startElement === null) {
> +            // 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 = findParentSpan(e.target);
> +            if (endElement === null) {
> +                // 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);
> +
> +    }
> +
> +    sourcelines.addEventListener('click', lineSelectStart);
> +
> +}
> +
> +//** return a <div id="followlines"> and inner cancel <button> elements */
> +function followlinesBox(targetUri, fromline, toline) {
> +    // <div id="followlines">
> +    var div = document.createElement('div');
> +    div.id = 'followlines';
> +
> +    //   <div class="followlines-cancel">
> +    var buttonDiv = document.createElement('div');
> +    buttonDiv.classList.add('followlines-cancel');
> +
> +    //     <button>x</button>
> +    var button = document.createElement('button');
> +    button.textContent = 'x';
> +    buttonDiv.appendChild(button);
> +    div.appendChild(buttonDiv);
> +
> +    //   <div class="followlines-link">
> +    var aDiv = document.createElement('div');
> +    aDiv.classList.add('followlines-link');
> +
> +    //     <a href="/log/<rev>/<file>?patch=&linerange=...">
> +    var a = document.createElement('a');
> +    var url = targetUri + '?patch=&linerange=' + fromline + ':' + toline;
> +    a.setAttribute('href', url);
> +    a.textContent = 'follow lines ' + fromline + ':' + toline;
> +    aDiv.appendChild(a);
> +    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>
>
> _______________________________________________
> Mercurial-devel mailing list
> Mercurial-devel@mercurial-scm.org
> https://www.mercurial-scm.org/mailman/listinfo/mercurial-devel
>
Yuya Nishihara - April 2, 2017, 9:14 a.m.
On Fri, 31 Mar 2017 18:53:28 -0700, Gregory Szorc wrote:
> On Fri, Mar 31, 2017 at 4:47 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 be7965e3afe82be35d258a2cff12b389a857ef88
> > # Parent  dea2a17cbfd00bf08ee87b3e44b1c71499189f89
> > # Available At http://hg.logilab.org/users/dlaxalde/hg
> > #              hg pull http://hg.logilab.org/users/dlaxalde/hg -r
> > be7965e3afe8
> > hgweb: expose a followlines UI in filerevision view
> 
> This looks good to me and I think it can be queued.

Queued per Gregory's review, thanks.

> > 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,163 @@
> > +// linerangelog.js - JavaScript utilities for followlines UI
> > +//
> > +// Copyright 2017 Logilab SA <contact@logilab.fr>
> > +//
> > +// This software may be used and distributed according to the terms of the
> > +// GNU General Public License version 2 or any later version.
> > +
> > +//** Install event listeners for line block selection and followlines
> > action */
> > +function installLineSelect() {

Do we need (function () {})() trick to not pollute the global namespace?

> > --- 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;
> > +}

I saw this cursor in other pages such as "rev/{node}". Perhaps the js
function needs to insert new class to change the cursor?

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,163 @@ 
+// linerangelog.js - JavaScript utilities for followlines UI
+//
+// Copyright 2017 Logilab SA <contact@logilab.fr>
+//
+// This software may be used and distributed according to the terms of the
+// GNU General Public License version 2 or any later version.
+
+//** 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;
+    }
+
+    // retrieve all direct <span> children of <pre class="sourcelines">
+    var spans = Array.prototype.filter.call(
+        sourcelines.children,
+        function(x) { return x.tagName === 'SPAN' });
+
+    var lineSelectedCSSClass = 'followlines-selected';
+
+    //** add CSS class on <span> element in `from`-`to` line range */
+    function addSelectedCSSClass(from, to) {
+        for (var i = from; i <= to; i++) {
+            spans[i].classList.add(lineSelectedCSSClass);
+        }
+    }
+
+    //** remove CSS class from previously selected lines */
+    function removeSelectedCSSClass() {
+        var elements = sourcelines.getElementsByClassName(
+            lineSelectedCSSClass);
+        while (elements.length) {
+            elements[0].classList.remove(lineSelectedCSSClass);
+        }
+    }
+
+    // ** return the <span> element parent of `element` */
+    function findParentSpan(element) {
+        var parent = element.parentElement;
+        if (parent === null) {
+            return null;
+        }
+        if (element.tagName == 'SPAN' && parent.isSameNode(sourcelines)) {
+            return element;
+        }
+        return findParentSpan(parent);
+    }
+
+    //** event handler for "click" on the first line of a block */
+    function lineSelectStart(e) {
+        var startElement = findParentSpan(e.target);
+        if (startElement === null) {
+            // 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 = findParentSpan(e.target);
+            if (endElement === null) {
+                // 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);
+
+    }
+
+    sourcelines.addEventListener('click', lineSelectStart);
+
+}
+
+//** return a <div id="followlines"> and inner cancel <button> elements */
+function followlinesBox(targetUri, fromline, toline) {
+    // <div id="followlines">
+    var div = document.createElement('div');
+    div.id = 'followlines';
+
+    //   <div class="followlines-cancel">
+    var buttonDiv = document.createElement('div');
+    buttonDiv.classList.add('followlines-cancel');
+
+    //     <button>x</button>
+    var button = document.createElement('button');
+    button.textContent = 'x';
+    buttonDiv.appendChild(button);
+    div.appendChild(buttonDiv);
+
+    //   <div class="followlines-link">
+    var aDiv = document.createElement('div');
+    aDiv.classList.add('followlines-link');
+
+    //     <a href="/log/<rev>/<file>?patch=&linerange=...">
+    var a = document.createElement('a');
+    var url = targetUri + '?patch=&linerange=' + fromline + ':' + toline;
+    a.setAttribute('href', url);
+    a.textContent = 'follow lines ' + fromline + ':' + toline;
+    aDiv.appendChild(a);
+    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>