/**
diff-related JS APIs for fossil.
*/
"use strict";
window.fossil.onPageLoad(function(){
/**
Adds toggle checkboxes to each file entry in the diff views for
/info and similar pages.
*/
const D = window.fossil.dom;
const addToggle = function(diffElem){
const sib = diffElem.previousElementSibling,
btn = sib ? D.addClass(D.checkbox(true), 'diff-toggle') : 0;
if(!sib) return;
D.append(sib,btn);
btn.addEventListener('click', function(){
diffElem.classList.toggle('hidden');
}, false);
};
document.querySelectorAll('table.diff').forEach(addToggle);
});
window.fossil.onPageLoad(function(){
const F = window.fossil, D = F.dom;
const Diff = F.diff = {
e:{/*certain cached DOM elements*/},
config: {
chunkLoadLines: 20,
chunkFetch: {
/* Default callack handlers for Diff.fetchArtifactChunk(),
unless overridden by options passeed to that function. */
beforesend: function(){},
aftersend: function(){},
onerror: function(e){
F.toast.error("XHR error: ",e.message);
}
}
}
};
/**
Uses the /jchunk AJAX route to fetch specific lines of a given
artifact. The argument must be an Object suitable for passing as
the second argument to fossil.fetch(). Its urlParams property
must be an object with these properties:
{
name: full hash of the target file,
from: first 1-based line number of the file to fetch (inclusive),
to: last 1-based line number of the file to fetch (inclusive)
}
The fetchOpt object is NOT cloned for use by the call: it is used
as-is and may be modified by this call. Thus callers "really
should" pass a temporary object, not a long-lived one.
If fetchOpt does not define any of the (beforesend, aftersend,
onerror) callbacks, the defaults from fossil.diff.config.chunkFetch
are used, so any given client page may override those to provide
page-level default handling.
Note that onload callback is ostensibly optional but this
function is not of much use without an onload
handler. Conversely, the default onerror handler is often
customized on a per-page basis to send the error output somewhere
where the user can see it.
The response, on success, will be an array of strings, each entry
being one line from the requested artifact. If the 'to' line is
greater than the length of the file, the array will be shorter
than (to-from) lines.
The /jchunk route reports errors via JSON objects with
an "error" string property describing the problem.
This is an async operation. Returns the fossil object.
*/
Diff.fetchArtifactChunk = function(fetchOpt){
if(!fetchOpt.beforesend) fetchOpt.beforesend = Diff.config.chunkFetch.beforesend;
if(!fetchOpt.aftersend) fetchOpt.aftersend = Diff.config.chunkFetch.aftersend;
if(!fetchOpt.onerror) fetchOpt.onerror = Diff.config.chunkFetch.onerror;
fetchOpt.responseType = 'json';
return F.fetch('jchunk', fetchOpt);
};
/**
Extracts either the starting or ending line number from a
line-numer column in the given tr. isSplit must be true if tr
represents a split diff, else false. Expects its tr to be valid:
GIGO applies. Returns the starting line number if getStart, else
the ending line number. Returns the line number from the LHS file
if getLHS is true, else the RHS.
*/
const extractLineNo = function f(getLHS, getStart, tr, isSplit){
if(!f.rx){
f.rx = {
start: /^\s*(\d+)/,
end: /(\d+)\n?$/
}
}
const td = tr.querySelector('td:nth-child('+(
/* TD element with the line numbers */
getLHS ? 1 : (isSplit ? 4 : 2)
)+')');
const m = f.rx[getStart ? 'start' : 'end'].exec(td.innerText);
return m ? +m[1] : undefined/*"shouldn't happen"*/;
};
/**
Fetches /jchunk for the given TR element then replaces the TR's
contents with data from the result of that request.
*/
const fetchTrChunk = function(tr){
if(tr.dataset.xfer /* already being fetched */) return;
const table = tr.parentElement.parentElement;
const hash = table.dataset.lefthash;
if(!hash) return;
const isSbs = table.classList.contains('splitdiff')/*else udiff*/;
tr.dataset.xfer = 1 /* sentinel against multiple concurrent ajax requests */;
const lineTo = +tr.dataset.endln;
var lineFrom = +tr.dataset.startln;
/* TODO: for the time being, for simplicity, we'll read the whole
[startln, endln] chunk. "Later on" we'll maybe want to read it in
chunks of, say, 20 lines or so, adjusting lineFrom to be 1 if it would
be less than 1. */
Diff.fetchArtifactChunk({
urlParams:{
name: hash,
from: lineFrom,
to: lineTo
},
aftersend: function(){
delete tr.dataset.xfer;
Diff.config.chunkFetch.aftersend.apply(
this, Array.prototype.slice.call(arguments,0)
);
},
onload: function(result){
//console.debug("Chunk result: ",result);
/* Replace content of tr.diffskip with the fetches result.
When we refactor this to load in smaller chunks, we'll instead
need to keep this skipper in place and:
- Add a new TR above or above it, as apropriate.
- Change the TR.dataset.startln/endln values to account for
the just-fetched set.
*/
D.clearElement(tr);
const cols = [], preCode = [D.pre()], preLines = [D.pre(), D.pre()];
if(isSbs){
cols.push(D.addClass(D.td(tr), 'diffln', 'difflnl'));
cols.push(D.addClass(D.td(tr), 'difftxt', 'difftxtl'));
cols.push(D.addClass(D.td(tr), 'diffsep'));
cols.push(D.addClass(D.td(tr), 'diffln', 'difflnr'));
cols.push(D.addClass(D.td(tr), 'difftxt', 'difftxtr'));
D.append(cols[0], preLines[0]);
D.append(cols[1], preCode[0]);
D.append(cols[3], preLines[1]);
preCode.push(D.pre());
D.append(cols[4], preCode[1]);
}else{
cols.push(D.addClass(D.td(tr), 'diffln', 'difflnl'));
cols.push(D.addClass(D.td(tr), 'diffln', 'difflnr'));
cols.push(D.addClass(D.td(tr), 'diffsep'));
cols.push(D.addClass(D.td(tr), 'difftxt', 'difftxtu'));
D.append(cols[0], preLines[0]);
D.append(cols[1], preLines[1]);
D.append(cols[3], preCode[0]);
}
let lineno = [], i;
for( i = lineFrom; i <= lineTo; ++i ){
lineno.push(i);
}
preLines[0].append(lineno.join('\n')+'\n');
if(1){
const code = result.join('\n')+'\n';
preCode.forEach((e)=>e.innerText = code);
}
//console.debug("Updated TR",tr);
Diff.initTableDiff(table).checkTableWidth(true);
/*
Reminders to self during development:
SBS diff col layout:
<td.diffln.difflnl><pre>...LHS line numbers...</pre></td>
<td.difftxt.difftxtl><pre>...code lines...</pre></td>
<td.diffsep>empty for this case (common lines)</td>
<td.diffln.difflnr><pre>...RHS line numbers...</pre></td>
<td.difftxt.difftxtr><pre>...dupe of col 2</pre></td>
Unified diff col layout:
<td.diffln.difflnl><pre>LHS line numbers</pre></td>
<td.diffln.difflnr><pre>RHS line numbers</pre></td>
<td.diffsep>empty in this case (common lines)</td>
<td.difftxt.difftxtu><pre>code line</pre></td>
C-side TODOs:
- If we have that data readily available, it would be a big
help (simplify our line calculations) if we stored the line
number ranges in all elements which have that state handy.
*/
}
});
};
/**
Installs chunk-loading controls into TR element tr. isSplit is true
if the parent table is a split diff, else false.)
The goal is to base these controls closely on github's, a good example
of which, for use as a model, is:
https://github.com/msteveb/autosetup/commit/235925e914a52a542
*/
Diff.ChunkLoadControls = function(isSplit, tr){
this.isSplit = isSplit;
this.e = {/*DOM elements*/};
this.pos = {
start: +tr.dataset.startln,
end: +tr.dataset.endln
};
tr.$chunker = this /* keep GC from reaping this */;
this.e.tr = tr;
D.clearElement(tr);
this.e.td = D.addClass(
D.attr(D.td(tr), 'colspan', isSplit ? 5 : 4),
'chunkctrl'
);
this.e.btnWrapper = D.div();
D.append(this.e.td, this.e.btnWrapper);
/**
Depending on various factors, we need one of:
- A single button to load all lines then remove this control
- A single button to load the initial chunk
- Two buttons: one to load upwards, one to load downwards
*/
if(tr.nextElementSibling){
this.pos.next = {
startLhs: extractLineNo(true, true, tr.nextElementSibling, isSplit),
startRhs: extractLineNo(false, true, tr.nextElementSibling, isSplit)
};
}
if(tr.previousElementSibling){
this.pos.prev = {
endLhs: extractLineNo(true, false, tr.previousElementSibling, isSplit),
endRhs: extractLineNo(false, false, tr.previousElementSibling, isSplit)
};
}
let btnUp = false, btnDown = false;
if(this.pos.prev && this.pos.next
&& ((this.pos.next.startLhs - this.pos.prev.endLhs)
<= Diff.config.chunkLoadLines)){
/* Place a single button to load the whole block, rather
than separate up/down buttons. */
btnDown = false;
btnUp = D.append(
D.addClass(D.span(), 'button', 'up', 'down'),
D.append(D.span(), this.config.glyphDown, this.config.glyphUp)
);
}else{
/* Figure out which chunk-load buttons to add... */
if(this.pos.prev){
btnDown = D.append(
D.addClass(D.span(), 'button', 'down'),
D.append(D.span(), this.config.glyphDown)
);
}
if(this.pos.next){
btnUp = D.append(
D.addClass(D.span(), 'button', 'up'),
D.append(D.span(), this.config.glyphUp)
);
}
}
if(btnDown) D.append(this.e.btnWrapper, btnDown);
if(btnUp) D.append(this.e.btnWrapper, btnUp);
D.append(this.e.btnWrapper, D.append(D.span(), JSON.stringify(this.pos)));
};
Diff.ChunkLoadControls.prototype = {
config: {
glyphUp: '⇡', //'&#uarr;',
glyphDown: '⇣' //'&#darr;'
},
destroy: function(){
delete this.tr.$chunker;
D.remove(this.tr);
},
/**
Creates and returns a new TR element, including its TD elements (depending
on this.isSplit), but does not fill it with any information nor inject it
into the table (it doesn't know where to do so).
*/
newTR: function(){
const tr = D.tr();
if(this.isSplit){
D.append(D.addClass( D.td(tr), 'diffln', 'difflnl' ), D.pre());
D.append(D.addClass( D.td(tr), 'difftxt', 'difftxtl' ), D.pre());
D.addClass( D.td(tr), 'diffsep' );
D.append(D.addClass( D.td(tr), 'diffln', 'difflnr' ), D.pre());
D.append(D.addClass( D.td(tr), 'difftxt', 'difftxtr' ), D.pre());
}else{
D.append(D.addClass( D.td(tr), 'diffln', 'difflnl' ), D.pre());
D.append(D.addClass( D.td(tr), 'diffln', 'difflnr' ), D.pre());
D.addClass( D.td(tr), 'diffsep' );
D.append(D.addClass( D.td(tr), 'difftxt', 'difftxtu' ), D.pre());
}
return tr;
}
};
Diff.addDiffSkipHandlers = function(){
const tables = document.querySelectorAll('table.diff[data-lefthash]');
if(!tables.length) return F;
const addDiffSkipToTr = function f(isSplit, tr){
D.addClass(tr, 'jchunk');
//tr.addEventListener('click', f._handler, false);
/* TODO/in progress... */
new Diff.ChunkLoadControls(isSplit, tr);
};
tables.forEach(function(table){
table.querySelectorAll('tr.diffskip[data-startln]').forEach(function(tr){
addDiffSkipToTr(table.classList.contains('splitdiff')/*else udiff*/, tr);
});
});
};
Diff.addDiffSkipHandlers();
});
/**
2021-09-07: refactoring the following for use in the higher-level
fossil.*.js framework is pending. For now it's a copy/paste copy
of diff.js.
*/
/* Refinements to the display of unified and side-by-side diffs.
**
** In all cases, the table columns tagged with "difftxt" are expanded,
** where possible, to fill the width of the screen.
**
** For a side-by-side diff, if either column is two wide to fit on the
** display, scrollbars are added. The scrollbars are linked, so that
** both sides scroll together. Left and right arrows also scroll.
*/
window.fossil.onPageLoad(function(){
const SCROLL_LEN = 25;
const F = window.fossil, D = F.dom, Diff = F.diff;
Diff.checkTableWidth = function f(force){
if(undefined === f.lastWidth){
f.lastWidth = 0;
}
if( !force && document.body.clientWidth===f.lastWidth ) return this;
f.lastWidth = document.body.clientWidth;
let w = f.lastWidth*0.5 - 100;
if(force || !f.colsL){
f.colsL = document.querySelectorAll('td.difftxtl pre');
}
f.colsL.forEach(function(e){
e.style.width = w + "px";
e.style.maxWidth = w + "px";
});
if(force || !f.colsR){
f.colsR = document.querySelectorAll('td.difftxtr pre');
}
f.colsR.forEach(function(e){
e.style.width = w + "px";
e.style.maxWidth = w + "px";
});
if(!f.allDiffs){
f.allDiffs = document.querySelectorAll('table.diff');
}
w = f.lastWidth;
f.allDiffs.forEach((e)=>e.style.maxWidth = w + "px");
return this;
};
const scrollLeft = function(event){
//console.debug("scrollLeft",this,event);
const table = this.parentElement/*TD*/.parentElement/*TR*/.
parentElement/*TBODY*/.parentElement/*TABLE*/;
table.$txtPres.forEach((e)=>(e===this) ? 1 : (e.scrollLeft = this.scrollLeft));
return false;
};
Diff.initTableDiff = function f(diff){
if(!diff){
let i, diffs = document.querySelectorAll('table.splitdiff');
for(i=0; i<diffs.length; ++i){
f.call(this, diffs[i]);
}
return this;
}
diff.$txtCols = diff.querySelectorAll('td.difftxt');
diff.$txtPres = diff.querySelectorAll('td.difftxt pre');
var width = 0;
diff.$txtPres.forEach(function(e){
if(width < e.scrollWidth) width = e.scrollWidth;
});
//console.debug("diff.$txtPres =",diff.$txtPres);
diff.$txtCols.forEach((e)=>e.style.width = width + 'px');
diff.$txtPres.forEach(function(e){
e.style.maxWidth = width + 'px';
e.style.width = width + 'px';
if(!e.classList.contains('scroller')){
D.addClass(e, 'scroller');
e.addEventListener('scroll', scrollLeft, false);
}
});
diff.tabIndex = 0;
if(!diff.classList.contains('scroller')){
D.addClass(diff, 'scroller');
diff.addEventListener('keydown', function(e){
e = e || event;
const len = {37: -SCROLL_LEN, 39: SCROLL_LEN}[e.keyCode];
if( !len ) return;
this.$txtPres[0].scrollLeft += len;
return false;
}, false);
}
return this;
}
window.fossil.page.tweakSbsDiffs = function(){
document.querySelectorAll('table.splitdiff').forEach((e)=>Diff.initTableDiff);
};
Diff.initTableDiff().checkTableWidth();
window.addEventListener('resize', ()=>Diff.checkTableWidth());
}, false);