// 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) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 ``; } 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); })();