Spaces:
Running
Running
| // 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 `<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); | |
| })(); |