filediff-viewer / script.js
Meroar's picture
Make the diffs provide more visual feedback and add some clever, innovative functionality or visual analytics
c238fdf verified
// FileDiff Viewer - Line-by-line visual diff with moved lines and jump-to
(() => {
// State
const state = {
files: {
left: { name: '', size: 0, text: '' },
right: { name: '', size: 0, text: '' },
},
options: {
ignoreWhitespace: false,
syncScroll: true,
},
result: null, // diff result
syncing: false,
currentChangeIndex: -1,
changeNavigation: [],
showHeatmap: false,
showMinimap: true,
searchResults: [],
searchIndex: 0,
};
// Elements
const els = {
// file inputs and paste
fileLeft: document.getElementById('fileLeft'),
fileRight: document.getElementById('fileRight'),
pasteLeftBtn: document.getElementById('pasteLeftBtn'),
pasteRightBtn: document.getElementById('pasteRightBtn'),
pasteLeftWrap: document.getElementById('pasteLeftWrap'),
pasteRightWrap: document.getElementById('pasteRightWrap'),
textLeft: document.getElementById('textLeft'),
textRight: document.getElementById('textRight'),
applyLeftText: document.getElementById('applyLeftText'),
applyRightText: document.getElementById('applyRightText'),
cancelLeftText: document.getElementById('cancelLeftText'),
cancelRightText: document.getElementById('cancelRightText'),
leftSummary: document.getElementById('leftSummary'),
rightSummary: document.getElementById('rightSummary'),
// search
searchInput: document.getElementById('searchInput'),
searchBtn: document.getElementById('searchBtn'),
// options
ignoreWs: document.getElementById('ignoreWs'),
syncScroll: document.getElementById('syncScroll'),
// modal
infoBtn: document.getElementById('infoBtn'),
modalOverlay: document.getElementById('modalOverlay'),
modalClose: document.getElementById('modalClose'),
// meta
metaLeft: document.getElementById('metaLeft'),
metaRight: document.getElementById('metaRight'),
// panes and bodies
paneLeft: document.getElementById('paneLeft'),
paneRight: document.getElementById('paneRight'),
tbodyLeft: document.getElementById('tbodyLeft'),
tbodyRight: document.getElementById('tbodyRight'),
// line connectors
lineConnectors: document.getElementById('lineConnectors'),
// stats
statsWrap: document.getElementById('stats'),
statAdded: document.getElementById('statAdded'),
statDeleted: document.getElementById('statDeleted'),
statModified: document.getElementById('statModified'),
statMoved: document.getElementById('statMoved'),
statSimilarity: document.getElementById('statSimilarity'),
// analytics
analyticsWrap: document.getElementById('analytics'),
changeChart: document.getElementById('changeChart'),
changeHeatmap: document.getElementById('changeHeatmap'),
toggleHeatmap: document.getElementById('toggleHeatmap'),
toggleMinimap: document.getElementById('toggleMinimap'),
// minimap
minimap: document.getElementById('minimap'),
minimapContent: document.getElementById('minimapContent'),
};
// Utilities
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB safety limit
const byId = (id) => document.getElementById(id);
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const normalize = (s) => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const trimWs = (s) => s.trim();
const isBlank = (s) => s.length === 0;
const fileSizeHuman = (n) => {
if (n < 1024) return `${n} B`;
if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
return `${(n/1024/1024).toFixed(1)} MB`;
};
function similarity(a, b) {
// Jaccard similarity over tokens (naive but useful)
const ta = (a.trim().match(/\S+/g) || []);
const tb = (b.trim().match(/\S+/g) || []);
const sa = new Set(ta);
const sb = new Set(tb);
let inter = 0;
for (const t of sa) if (sb.has(t)) inter++;
const union = new Set([...ta, ...tb]).size || 1;
return inter / union;
}
// Diff engine
function lcsMatrix(a, b) {
const n = a.length, m = b.length;
const dp = Array(n + 1);
for (let i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
if (a[i] === b[j]) dp[i][j] = dp[i + 1][j + 1] + 1;
else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
return dp;
}
function backtrackLCS(dp, a, b) {
const pairs = [];
let i = 0, j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
pairs.push([i, j]);
i++; j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
i++;
} else {
j++;
}
}
return pairs; // array of [i, j] indices of equal lines
}
function findEqualRanges(pairs) {
if (!pairs.length) return [];
const ranges = [];
let startI = pairs[0][0], startJ = pairs[0][1], prevI = startI, prevJ = startJ;
for (let k = 1; k < pairs.length; k++) {
const [i, j] = pairs[k];
if (i === prevI + 1 && j === prevJ + 1) {
prevI = i; prevJ = j;
} else {
ranges.push([startI, prevI, startJ, prevJ]);
startI = i; startJ = j; prevI = i; prevJ = j;
}
}
ranges.push([startI, prevI, startJ, prevJ]);
return ranges;
}
function diffLines(textA, textB, ignoreWs) {
const A = normalize(textA).split('\n');
const B = normalize(textB).split('\n');
const a = ignoreWs ? A.map(trimWs) : A;
const b = ignoreWs ? B.map(trimWs) : B;
const dp = lcsMatrix(a, b);
const pairs = backtrackLCS(dp, a, b);
const ranges = findEqualRanges(pairs);
const aLen = a.length, bLen = b.length;
let ai = 0, bj = 0;
const aRes = [];
const bRes = [];
// Mapping for moved lines
const aToB = new Map(); // aIndex -> bIndex (for moved)
const bToA = new Map(); // bIndex -> aIndex (for moved)
function pushEqualRange(sI, eI, sJ, eJ) {
for (let k = 0; k <= eI - sI; k++) {
const i = sI + k, j = sJ + k;
aRes.push({ type: 'equal', aIndex: i, bIndex: null, aText: A[i], bText: null });
bRes.push({ type: 'equal', aIndex: null, bIndex: j, aText: null, bText: B[j] });
}
ai = eI + 1; bj = eJ + 1;
}
for (const [sI, eI, sJ, eJ] of ranges) {
// First, deletions/insertions/modifications between current ai..sI and bj..sJ
while (ai < sI && bj < sJ) {
const del = a[ai], ins = b[bj];
const sim = similarity(a[ai], b[bj]);
if (sim >= 0.55) {
// Modified pair
aRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: A[ai], bText: null, partner: bj });
bRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: null, bText: B[bj], partner: ai });
aToB.set(ai, bj);
bToA.set(bj, ai);
ai++; bj++;
} else {
aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null });
bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
ai++;
}
}
while (ai < sI) {
aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null });
bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
ai++;
}
while (bj < sJ) {
aRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
bRes.push({ type: 'inserted', aIndex: null, bIndex: bj, aText: null, bText: B[bj] });
bj++;
}
// Now equal run
pushEqualRange(sI, eI, sJ, eJ);
}
// Tail
while (ai < aLen && bj < bLen) {
const sim = similarity(a[ai], b[bj]);
if (sim >= 0.55) {
aRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: A[ai], bText: null, partner: bj });
bRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: null, bText: B[bj], partner: ai });
aToB.set(ai, bj);
bToA.set(bj, ai);
ai++; bj++;
} else {
aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null });
bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
ai++;
}
}
while (ai < aLen) {
aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null });
bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
ai++;
}
while (bj < bLen) {
aRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
bRes.push({ type: 'inserted', aIndex: null, bIndex: bj, aText: B[bj], bText: null });
bj++;
}
// Moved detection: single deleted lines whose partner is a single inserted line later
const aDelGroups = groupBy(aRes.filter(x => x.type === 'deleted'), (x) => x.aIndex);
const bInsGroups = groupBy(bRes.filter(x => x.type === 'inserted'), (x) => x.bIndex);
// Make reverse maps with indices
const aDeletedSet = new Set(aRes.filter(x => x.type === 'deleted').map(x => x.aIndex));
const bInsertedSet = new Set(bRes.filter(x => x.type === 'inserted').map(x => x.bIndex));
for (const [ai2, bj2] of aToB.entries()) {
// Already partners in modified; skip
continue;
}
// Consider unmatched singles by equality only
for (const ai2 of aDeletedSet) {
const bj2 = aToB.get(ai2);
if (bj2 != null && bInsertedSet.has(bj2)) {
// It could still be treated as moved if both are not in other relations
// We'll mark if they are exact equal (trim aware) and not already modified
const leftText = a[ai2];
const rightText = b[bj2];
if (leftText === rightText) {
// Tag both sides as moved and neutralize delete/insert
const aRow = aRes.find(x => x.aIndex === ai2 && x.type === 'deleted');
const bRow = bRes.find(x => x.bIndex === bj2 && x.type === 'inserted');
if (aRow && bRow) {
aRow.type = 'moved';
aRow.partner = bj2;
bRow.type = 'moved';
bRow.partner = ai2;
}
}
}
}
// Stats
const added = bRes.filter(x => x.type === 'inserted').length;
const deleted = aRes.filter(x => x.type === 'deleted').length;
const modified = aRes.filter(x => x.type === 'modified').length;
const moved = aRes.filter(x => x.type === 'moved').length;
return {
A, B,
aRes, bRes,
stats: { added, deleted, modified, moved }
};
}
function groupBy(arr, keyFn) {
const map = new Map();
for (const item of arr) {
const k = keyFn(item);
if (!map.has(k)) map.set(k, []);
map.get(k).push(item);
}
return map;
}
// Rendering
function renderDiff(result) {
const { aRes, bRes, A, B, stats } = result;
// Clear
els.tbodyLeft.innerHTML = '';
els.tbodyRight.innerHTML = '';
const fragLeft = document.createDocumentFragment();
const fragRight = document.createDocumentFragment();
for (let i = 0; i < aRes.length; i++) {
const ar = aRes[i];
const br = bRes[i];
const trL = document.createElement('tr');
trL.setAttribute('data-kind', ar.type);
trL.setAttribute('data-index', String(ar.aIndex ?? -1));
trL.id = ar.aIndex != null ? `L${ar.aIndex}` : `L-gap-${i}`;
const trR = document.createElement('tr');
trR.setAttribute('data-kind', br.type);
trR.setAttribute('data-index', String(br.bIndex ?? -1));
trR.id = br.bIndex != null ? `R${br.bIndex}` : `R-gap-${i}`;
// Line number cells
const tdNumL = document.createElement('td');
tdNumL.className = 'gutter';
tdNumL.textContent = ar.aIndex != null ? String(ar.aIndex + 1) : '';
const tdNumR = document.createElement('td');
tdNumR.className = 'gutter';
tdNumR.textContent = br.bIndex != null ? String(br.bIndex + 1) : '';
// Content cells
const tdContentL = document.createElement('td');
tdContentL.className = 'content';
tdContentL.classList.add('line');
tdContentL.classList.add(ar.type);
const tdContentR = document.createElement('td');
tdContentR.className = 'content';
tdContentR.classList.add('line');
tdContentR.classList.add(br.type);
// Prepare text
let leftText = '';
let rightText = '';
if (ar.type === 'equal') leftText = A[ar.aIndex] ?? '';
else if (ar.type === 'deleted') leftText = A[ar.aIndex] ?? '';
else if (ar.type === 'modified') leftText = A[ar.aIndex] ?? '';
else if (ar.type === 'moved') leftText = A[ar.aIndex] ?? '';
else leftText = '';
if (br.type === 'equal') rightText = B[br.bIndex] ?? '';
else if (br.type === 'inserted') rightText = B[br.bIndex] ?? '';
else if (br.type === 'modified') rightText = B[br.bIndex] ?? '';
else if (br.type === 'moved') rightText = B[br.bIndex] ?? '';
else rightText = '';
tdContentL.innerHTML = `${escapeHtml(leftText)}${renderActions(ar, 'left')}`;
tdContentR.innerHTML = `${escapeHtml(rightText)}${renderActions(br, 'right')}`;
trL.appendChild(tdNumL);
trL.appendChild(tdContentL);
trR.appendChild(tdNumR);
trR.appendChild(tdContentR);
fragLeft.appendChild(trL);
fragRight.appendChild(trR);
}
els.tbodyLeft.appendChild(fragLeft);
els.tbodyRight.appendChild(fragRight);
// Wire up actions
wireJumpButtons();
// Update stats
els.statsWrap.hidden = false;
els.statAdded.textContent = String(stats.added);
els.statDeleted.textContent = String(stats.deleted);
els.statModified.textContent = String(stats.modified);
els.statMoved.textContent = String(stats.moved);
}
function renderActions(row, side) {
// side: 'left' | 'right'
// Determine if a jump button is available
let partnerIndex = null;
if (row.type === 'moved' && row.partner != null) {
partnerIndex = row.partner;
} else if (row.type === 'modified' && row.partner != null) {
partnerIndex = row.partner;
}
if (partnerIndex == null) return '';
const targetId = side === 'left' ? `R${partnerIndex}` : `L${partnerIndex}`;
const label = side === 'left' ? 'Jump to B' : 'Jump to A';
return `<span class="actions"><button class="btn-jump" data-target="${targetId}" title="${label}" aria-label="${label}">⟷</button></span>`;
}
function wireJumpButtons() {
const buttons = document.querySelectorAll('.btn-jump');
buttons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const target = btn.getAttribute('data-target');
if (!target) return;
const el = document.getElementById(target);
if (!el) return;
el.classList.add('jump-indicator');
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
setTimeout(() => el.classList.remove('jump-indicator'), 900);
});
});
}
// Sync scrolling
function setupSyncScrolling() {
let last = 0;
els.paneLeft.addEventListener('scroll', () => {
if (!state.options.syncScroll || state.syncing) return;
state.syncing = true;
const ratio = els.paneLeft.scrollTop / Math.max(1, (els.paneLeft.scrollHeight - els.paneLeft.clientHeight));
const target = ratio * (els.paneRight.scrollHeight - els.paneRight.clientHeight);
els.paneRight.scrollTop = target;
setTimeout(() => state.syncing = false, 0);
});
els.paneRight.addEventListener('scroll', () => {
if (!state.options.syncScroll || state.syncing) return;
state.syncing = true;
const ratio = els.paneRight.scrollTop / Math.max(1, (els.paneRight.scrollHeight - els.paneRight.clientHeight));
const target = ratio * (els.paneLeft.scrollHeight - els.paneLeft.clientHeight);
els.paneLeft.scrollTop = target;
setTimeout(() => state.syncing = false, 0);
});
}
// Compute and render pipeline
function compute() {
const leftText = state.files.left.text || '';
const rightText = state.files.right.text || '';
// Guard: large content
if (leftText.length + rightText.length > 1_000_000) {
alert('Warning: Large content. Rendering may be slow.');
}
state.result = diffLines(leftText, rightText, state.options.ignoreWhitespace);
renderDiff(state.result);
}
// File loading helpers
async function handleFileInput(inputEl, side) {
const file = inputEl.files?.[0];
if (!file) return;
if (file.size > MAX_SIZE_BYTES) {
alert(`File "${file.name}" is too large (${fileSizeHuman(file.size)}). Max allowed is ${fileSizeHuman(MAX_SIZE_BYTES)}.`);
inputEl.value = '';
return;
}
try {
const text = await file.text();
state.files[side] = { name: file.name, size: file.size, text };
updateSummary(side);
compute();
} catch (err) {
console.error(err);
alert('Failed to read the selected file.');
}
}
function updateSummary(side) {
const data = state.files[side];
const sumEl = side === 'left' ? els.leftSummary : els.rightSummary;
if (!data.text) {
sumEl.textContent = 'No file selected.';
} else {
const lines = normalize(data.text).split('\n').length;
sumEl.textContent = `${data.name || '(pasted)'}${fileSizeHuman(data.size || data.text.length)}${lines} line${lines !== 1 ? 's' : ''}`;
}
const metaEl = side === 'left' ? els.metaLeft : els.metaRight;
metaEl.textContent = data.name ? data.name : '(pasted)';
}
// Paste panels
function togglePaste(side, show) {
const wrap = side === 'left' ? els.pasteLeftWrap : els.pasteRightWrap;
wrap.classList.toggle('hidden', !show);
const textarea = side === 'left' ? els.textLeft : els.textRight;
if (show) textarea.focus();
}
// Events
function wireEvents() {
// File inputs
els.fileLeft.addEventListener('change', () => handleFileInput(els.fileLeft, 'left'));
els.fileRight.addEventListener('change', () => handleFileInput(els.fileRight, 'right'));
// Paste buttons
els.pasteLeftBtn.addEventListener('click', () => togglePaste('left', true));
els.pasteRightBtn.addEventListener('click', () => togglePaste('right', true));
els.applyLeftText.addEventListener('click', () => {
const text = els.textLeft.value || '';
state.files.left = { name: '(pasted)', size: text.length, text };
els.textLeft.value = '';
togglePaste('left', false);
updateSummary('left');
compute();
});
els.applyRightText.addEventListener('click', () => {
const text = els.textRight.value || '';
state.files.right = { name: '(pasted)', size: text.length, text };
els.textRight.value = '';
togglePaste('right', false);
updateSummary('right');
compute();
});
els.cancelLeftText.addEventListener('click', () => {
els.textLeft.value = '';
togglePaste('left', false);
});
els.cancelRightText.addEventListener('click', () => {
els.textRight.value = '';
togglePaste('right', false);
});
// Options
els.ignoreWs.addEventListener('change', () => {
state.options.ignoreWhitespace = els.ignoreWs.checked;
if (state.files.left.text || state.files.right.text) compute();
});
els.syncScroll.addEventListener('change', () => {
state.options.syncScroll = els.syncScroll.checked;
});
// Initial syncing
setupSyncScrolling();
}
// Init
function init() {
wireEvents();
// Initial blank
updateSummary('left');
updateSummary('right');
}
document.addEventListener('DOMContentLoaded', init);
})();