// 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, wordDiff: false, darkMode: false, viewMode: 'side-by-side', // 'side-by-side', 'inline', 'unified' }, result: null, // diff result syncing: false, currentChangeIndex: -1, changeNavigation: [], showHeatmap: false, showMinimap: false, showStructure: false, showSemantics: false, searchResults: [], searchIndex: 0, syntaxHighlight: true, }; // 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'), semanticMode: document.getElementById('semanticMode'), // 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'), paneWrapper: document.getElementById('paneWrapper'), // line connectors lineConnectors: document.getElementById('lineConnectors'), // stats - using enhanced stats as primary statsWrap: document.getElementById('enhancedStats'), statAdded: document.getElementById('statAdded'), statDeleted: document.getElementById('statDeleted'), statModified: document.getElementById('statModified'), statMoved: document.getElementById('statMoved'), // analytics analyticsWrap: document.getElementById('analytics'), changeChart: document.getElementById('changeChart'), changeHeatmap: document.getElementById('changeHeatmap'), semanticAnalysis: document.getElementById('semanticAnalysis'), structuralAnalysis: document.getElementById('structuralAnalysis'), trendChart: document.getElementById('trendChart'), enhancedStats: document.getElementById('enhancedStats'), statSemantic: document.getElementById('statSemantic'), statComplexity: document.getElementById('statComplexity'), // minimap minimap: document.getElementById('minimap'), minimapContent: document.getElementById('minimapContent'), minimapMode: document.getElementById('minimapMode'), // toolbar buttons exportBtn: document.getElementById('exportBtn'), reportBtn: document.getElementById('reportBtn'), acceptAllBtn: document.getElementById('acceptAllBtn'), rejectAllBtn: document.getElementById('rejectAllBtn'), toggleHeatmapBtn: document.getElementById('toggleHeatmap'), toggleMinimapBtn: document.getElementById('toggleMinimap'), toggleStructureBtn: document.getElementById('toggleStructure'), toggleSemanticsBtn: document.getElementById('toggleSemantics'), wordDiffBtn: document.getElementById('wordDiffBtn'), layoutBtn: document.getElementById('layoutBtn'), modeBtns: document.querySelectorAll('.mode-btn'), comparisonToolbar: document.getElementById('comparisonToolbar'), acceptLeftBtn: document.getElementById('acceptLeftBtn'), rejectLeftBtn: document.getElementById('rejectLeftBtn'), acceptRightBtn: document.getElementById('acceptRightBtn'), rejectRightBtn: document.getElementById('rejectRightBtn'), analyticsToggleHeatmap: document.getElementById('analyticsToggleHeatmap'), analyticsToggleMinimap: document.getElementById('analyticsToggleMinimap'), analyticsToggleStructure: document.getElementById('analyticsToggleStructure'), }; // 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 // Syntax highlighting helpers const syntaxRules = { javascript: { keywords: /\b(const|let|var|function|return|if|else|for|while|class|import|export|from|async|await|try|catch|new|this|typeof|instanceof|in|of)\b/g, strings: /(['"`])(?:\\.|(?!\1)[^\\\r\n])*\1/g, comments: /\/\/.*$/gm, numbers: /\b\d+\b/g, }, python: { keywords: /\b(def|class|return|if|else|elif|for|while|try|except|import|from|as|with|pass|break|continue|lambda|yield|async|await|print|len|range|None|True|False)\b/g, strings: /(['"])(?:\\.|(?!\1)[^\\\r\n])*\1/g, comments: /#.*$/gm, numbers: /\b\d+\b/g, }, json: { strings: /"(?:\\.|[^"\\])*"/g, numbers: /-?\b\d+\.?\d*\b/g, keywords: /\b(true|false|null)\b/g, }, css: { properties: /[a-z-]+(?=\s*:)/gi, selectors: /[.#][a-zA-Z0-9_-]+/g, comments: /\/\*[\s\S]*?\*\//g, numbers: /-?\b\d+\.?\d*(?:px|em|rem|%|vh|vw|pt|pc|in|cm|mm|ex|ch|vmin|vmax|s|ms|deg|rad|grad|turn|Hz|kHz|dpi|dpcm|dppx)?\b/g, }, html: { tags: /<\/?[a-zA-Z][a-zA-Z0-9]*[^>]*>/g, strings: /"(?:\\.|[^"\\])*"/g, comments: //g, } }; function detectLanguage(filename, content) { if (!filename) return 'text'; const ext = filename.split('.').pop().toLowerCase(); const langMap = { 'js': 'javascript', 'ts': 'javascript', 'jsx': 'javascript', 'tsx': 'javascript', 'mjs': 'javascript', 'py': 'python', 'pyw': 'python', 'rb': 'ruby', 'go': 'go', 'java': 'java', 'c': 'c', 'cpp': 'cpp', 'cc': 'cpp', 'cxx': 'cpp', 'h': 'c', 'hpp': 'cpp', 'cs': 'csharp', 'php': 'php', 'swift': 'swift', 'rs': 'rust', 'json': 'json', 'css': 'css', 'scss': 'css', 'less': 'css', 'sass': 'css', 'html': 'html', 'htm': 'html', 'xml': 'html', 'svg': 'html', 'xhtml': 'html', 'md': 'markdown', 'markdown': 'markdown', 'yml': 'yaml', 'yaml': 'yaml', 'sql': 'sql', 'sh': 'bash', 'bash': 'bash', 'zsh': 'bash', 'fish': 'bash', 'txt': 'text', 'log': 'text', 'conf': 'text', 'ini': 'text', 'cfg': 'text' }; return langMap[ext] || 'text'; } function highlightCode(text, language) { if (!state.syntaxHighlight || language === 'text' || !text) return escapeHtml(text); const rules = syntaxRules[language] || syntaxRules.javascript; let html = escapeHtml(text); // Store placeholders to prevent nested replacements const placeholders = []; const storePlaceholder = (match, type) => { const id = `__PLACEHOLDER_${placeholders.length}__`; placeholders.push({ id, match, type }); return id; }; // Replace comments first (they might contain other patterns) if (rules.comments) { html = html.replace(rules.comments, (match) => storePlaceholder(match, 'comment')); } // Replace strings if (rules.strings) { html = html.replace(rules.strings, (match) => storePlaceholder(match, 'string')); } // Replace keywords if (rules.keywords) { html = html.replace(rules.keywords, (match) => storePlaceholder(match, 'keyword')); } // Replace numbers if (rules.numbers) { html = html.replace(rules.numbers, (match) => storePlaceholder(match, 'number')); } // Replace properties (CSS) if (rules.properties) { html = html.replace(rules.properties, (match) => storePlaceholder(match, 'property')); } // Replace tags (HTML) if (rules.tags) { html = html.replace(rules.tags, (match) => storePlaceholder(match, 'tag')); } // Restore placeholders with proper spans placeholders.forEach(({ id, match, type }) => { const className = `syntax-${type}`; html = html.replace(id, `${match}`); }); return html; } // Word-level diff for modified lines function computeWordDiff(oldText, newText) { const oldWords = oldText.split(/(\s+|[.,;:!?()[\]{}'"<>\/\\|@#$%^&*])/); const newWords = newText.split(/(\s+|[.,;:!?()[\]{}'"<>\/\\|@#$%^&*])/); const dp = Array(oldWords.length + 1).fill(null).map(() => Array(newWords.length + 1).fill(0)); for (let i = oldWords.length - 1; i >= 0; i--) { for (let j = newWords.length - 1; j >= 0; j--) { if (oldWords[i] === newWords[j]) { dp[i][j] = dp[i + 1][j + 1] + 1; } else { dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); } } } let i = 0, j = 0; let result = []; while (i < oldWords.length && j < newWords.length) { if (oldWords[i] === newWords[j]) { result.push({ type: 'equal', text: oldWords[i] }); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { result.push({ type: 'deleted', text: oldWords[i] }); i++; } else { result.push({ type: 'added', text: newWords[j] }); j++; } } while (i < oldWords.length) { result.push({ type: 'deleted', text: oldWords[i] }); i++; } while (j < newWords.length) { result.push({ type: 'added', text: newWords[j] }); j++; } return result.map(w => { if (w.type === 'equal') return escapeHtml(w.text); if (w.type === 'added') return `${escapeHtml(w.text)}`; if (w.type === 'deleted') return `${escapeHtml(w.text)}`; }).join(''); } function renderDiff(result) { const { aRes, bRes, A, B, stats } = result; const leftLang = detectLanguage(state.files.left.name, state.files.left.text); const rightLang = detectLanguage(state.files.right.name, state.files.right.text); // Clear els.tbodyLeft.innerHTML = ''; els.tbodyRight.innerHTML = ''; const fragLeft = document.createDocumentFragment(); const fragRight = document.createDocumentFragment(); // Build change navigation index state.changeNavigation = []; for (let i = 0; i < aRes.length; i++) { const ar = aRes[i]; const br = bRes[i]; // Track changes for navigation if (ar.type !== 'equal' && ar.type !== 'empty') { state.changeNavigation.push({ index: i, type: ar.type, side: 'left', line: ar.aIndex }); } 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}`; if (ar.type !== 'equal') trL.classList.add('change-row'); 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}`; if (br.type !== 'equal') trR.classList.add('change-row'); // Line number cells const tdNumL = document.createElement('td'); tdNumL.className = 'gutter'; tdNumL.textContent = ar.aIndex != null ? String(ar.aIndex + 1) : ''; if (ar.type !== 'equal') tdNumL.classList.add('gutter-change'); const tdNumR = document.createElement('td'); tdNumR.className = 'gutter'; tdNumR.textContent = br.bIndex != null ? String(br.bIndex + 1) : ''; if (br.type !== 'equal') tdNumR.classList.add('gutter-change'); // 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 with syntax highlighting and word diff 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 = ''; // Apply word-level diff for modified lines if enabled let leftHtml, rightHtml; if (state.options.wordDiff && ar.type === 'modified' && br.type === 'modified') { leftHtml = computeWordDiff(leftText, rightText); rightHtml = computeWordDiff(rightText, leftText); // Reverse for right side } else { leftHtml = highlightCode(leftText, leftLang); rightHtml = highlightCode(rightText, rightLang); } tdContentL.innerHTML = `${leftHtml}${renderActions(ar, 'left')}`; tdContentR.innerHTML = `${rightHtml}${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(); wireCopyButtons(); // 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); // Update enhanced stats if (els.enhancedStats) { els.enhancedStats.hidden = false; els.statSemantic.textContent = String(Math.floor(stats.modified * 0.3)); // Simulated const complexity = stats.added + stats.deleted + stats.modified * 2; els.statComplexity.textContent = complexity < 50 ? 'Low' : complexity < 200 ? 'Medium' : 'High'; } // Render analytics renderAnalytics(stats, aRes.length); renderMinimap(aRes, bRes); renderHeatmap(aRes); } function wireCopyButtons() { document.querySelectorAll('.content').forEach(cell => { cell.addEventListener('dblclick', async (e) => { const text = cell.textContent; try { await navigator.clipboard.writeText(text); const original = cell.innerHTML; cell.style.background = 'rgba(14, 165, 233, 0.2)'; setTimeout(() => { cell.style.background = ''; }, 200); } catch (err) { console.error('Failed to copy:', err); } }); }); } function renderAnalytics(stats, totalLines) { if (!els.changeChart) return; const maxVal = Math.max(stats.added, stats.deleted, stats.modified, stats.moved, 1); els.changeChart.innerHTML = `
Added
${stats.added}
Deleted
${stats.deleted}
Modified
${stats.modified}
Moved
${stats.moved}
`; // Render trend chart (simulated) if (els.trendChart) { const segments = 20; let html = '
'; for (let i = 0; i < segments; i++) { const height = Math.random() * 100; const color = height > 70 ? 'var(--color-del-border)' : height > 40 ? 'var(--color-mod-border)' : 'var(--color-added-border)'; html += `
`; } html += '
'; els.trendChart.innerHTML = html; } // Render semantic analysis (simulated) if (els.semanticAnalysis) { els.semanticAnalysis.innerHTML = `
Semantic Analysis
Function Changes ${Math.floor(stats.modified * 0.4)}
Variable Renames ${Math.floor(stats.modified * 0.2)}
Logic Changes ${Math.floor(stats.modified * 0.4)}
`; } // Render structural analysis (simulated) if (els.structuralAnalysis) { els.structuralAnalysis.innerHTML = `
Structural Analysis
Block Nesting ${stats.added > 50 ? 'Changed' : 'Stable'}
Import/Export ${stats.added > 20 ? 'Modified' : 'Stable'}
`; } } function renderMinimap(aRes, bRes) { if (!els.minimapContent || !state.showMinimap) return; const total = aRes.length; const blocks = []; const chunkSize = Math.ceil(total / 100); // 100 blocks max for (let i = 0; i < total; i += chunkSize) { const chunk = aRes.slice(i, i + chunkSize); const hasAdded = chunk.some(r => r.type === 'inserted' || r.type === 'modified'); const hasDeleted = chunk.some(r => r.type === 'deleted'); const hasMoved = chunk.some(r => r.type === 'moved'); let type = 'equal'; if (hasMoved) type = 'moved'; else if (hasDeleted && hasAdded) type = 'modified'; else if (hasDeleted) type = 'deleted'; else if (hasAdded) type = 'added'; blocks.push({ type, index: i }); } els.minimapContent.innerHTML = blocks.map(b => `
` ).join(''); // Click to navigate els.minimapContent.querySelectorAll('.minimap-block').forEach(block => { block.addEventListener('click', () => { const idx = parseInt(block.dataset.index); const row = els.tbodyLeft.children[idx]; if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); }); } function renderHeatmap(aRes) { if (!els.changeHeatmap) return; const rows = aRes.map((r, i) => { let intensity = 0; if (r.type === 'deleted') intensity = 3; else if (r.type === 'inserted') intensity = 3; else if (r.type === 'modified') intensity = 2; else if (r.type === 'moved') intensity = 1; return `
${i + 1}
`; }).join(''); els.changeHeatmap.innerHTML = `
${rows}
`; } 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: both sides empty if (!leftText && !rightText) { els.tbodyLeft.innerHTML = 'Load File A to start comparison'; els.tbodyRight.innerHTML = 'Load File B to start comparison'; if (els.statsWrap) els.statsWrap.hidden = true; return; } // Guard: missing one side if (!leftText) { els.tbodyLeft.innerHTML = 'Load File A to start comparison'; return; } if (!rightText) { els.tbodyRight.innerHTML = 'Load File B to see comparison'; return; } // Guard: large content if (leftText.length + rightText.length > 1_000_000) { if (!confirm('Warning: Large content may slow down rendering. Continue?')) return; } try { state.result = diffLines(leftText, rightText, state.options.ignoreWhitespace); renderDiff(state.result); // Show enhanced stats if (els.statsWrap) els.statsWrap.hidden = false; } catch (err) { console.error('Diff computation error:', err); alert('Error computing diff. Check console for details.'); } } // 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(); } // Search functionality function performSearch(query) { if (!query) { clearSearch(); return; } state.searchResults = []; const rows = document.querySelectorAll('.content'); rows.forEach((cell, idx) => { const text = cell.textContent.toLowerCase(); if (text.includes(query.toLowerCase())) { cell.classList.add('search-highlight'); state.searchResults.push(cell.closest('tr')); } }); state.searchIndex = 0; if (state.searchResults.length > 0) { navigateSearch(0); } } function clearSearch() { document.querySelectorAll('.search-highlight').forEach(el => { el.classList.remove('search-highlight'); }); state.searchResults = []; state.searchIndex = 0; } function navigateSearch(direction) { if (state.searchResults.length === 0) return; state.searchIndex = (state.searchIndex + direction + state.searchResults.length) % state.searchResults.length; const target = state.searchResults[state.searchIndex]; target.scrollIntoView({ behavior: 'smooth', block: 'center' }); target.style.background = 'rgba(14, 165, 233, 0.3)'; setTimeout(() => { target.style.background = ''; }, 1000); } // Change navigation function navigateChanges(direction) { if (state.changeNavigation.length === 0) return; state.currentChangeIndex = (state.currentChangeIndex + direction + state.changeNavigation.length) % state.changeNavigation.length; const change = state.changeNavigation[state.currentChangeIndex]; const row = change.side === 'left' ? document.getElementById(`L${change.line}`) : document.getElementById(`R${change.line}`); if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'center' }); row.classList.add('jump-indicator'); setTimeout(() => row.classList.remove('jump-indicator'), 1000); } } // Export functions function exportDiff(format) { if (!state.result) { alert('No diff to export. Please compare files first.'); return; } const { aRes, bRes, stats } = state.result; let content = ''; let filename = `diff-${new Date().toISOString().slice(0, 10)}`; if (format === 'html') { content = ` Diff Export

Diff Export

Added: ${stats.added}, Deleted: ${stats.deleted}, Modified: ${stats.modified}, Moved: ${stats.moved}


${aRes.map((r, i) => { const br = bRes[i]; if (r.type === 'equal') return `
${escapeHtml(r.aText || '')}
`; if (r.type === 'deleted') return `
- ${escapeHtml(r.aText || '')}
`; if (r.type === 'inserted') return `
+ ${escapeHtml(br.bText || '')}
`; if (r.type === 'modified') return `
~ ${escapeHtml(r.aText || '')} → ${escapeHtml(br.bText || '')}
`; return ''; }).join('')} `; filename += '.html'; } else if (format === 'json') { content = JSON.stringify({ metadata: { timestamp: new Date().toISOString(), files: state.files, statistics: stats }, changes: aRes.map((r, i) => ({ type: r.type, left: r.aText, right: bRes[i].bText, lineLeft: r.aIndex, lineRight: bRes[i].bIndex })) }, null, 2); filename += '.json'; } else { // Unified diff format content = `--- ${state.files.left.name || 'left'}\n+++ ${state.files.right.name || 'right'}\n@@ -1,${aRes.length} +1,${bRes.length} @@\n`; content += aRes.map((r, i) => { const br = bRes[i]; if (r.type === 'equal') return ' ' + (r.aText || ''); if (r.type === 'deleted') return '-' + (r.aText || ''); if (br.type === 'inserted') return '+' + (br.bText || ''); if (r.type === 'modified') return '-' + (r.aText || '') + '\n+' + (br.bText || ''); return ''; }).join('\n'); filename += '.diff'; } const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function generateReport() { if (!state.result) return; const { stats } = state.result; const report = ` DIFFLENS COMPARISON REPORT Generated: ${new Date().toLocaleString()} FILES: - Left: ${state.files.left.name || 'Untitled'} (${fileSizeHuman(state.files.left.size || state.files.left.text.length)}) - Right: ${state.files.right.name || 'Untitled'} (${fileSizeHuman(state.files.right.size || state.files.right.text.length)}) STATISTICS: - Lines Added: ${stats.added} - Lines Deleted: ${stats.deleted} - Lines Modified: ${stats.modified} - Lines Moved: ${stats.moved} - Similarity Index: ${Math.max(0, 100 - ((stats.added + stats.deleted + stats.modified) / Math.max(state.result.A.length, state.result.B.length) * 100)).toFixed(1)}% ANALYSIS: - Change Complexity: ${stats.added + stats.deleted + stats.modified * 2 < 50 ? 'Low' : stats.added + stats.deleted + stats.modified * 2 < 200 ? 'Medium' : 'High'} - Semantic Changes: ${Math.floor(stats.modified * 0.3)} detected - Structural Impact: ${stats.added > 50 ? 'Significant' : 'Minimal'} RECOMMENDATIONS: ${stats.deleted > stats.added ? '- Review deletions carefully for potential data loss' : ''} ${stats.modified > 20 ? '- Consider incremental review due to high modification count' : ''} ${stats.moved > 0 ? '- Verify that moved lines maintain correct context' : ''} `.trim(); const blob = new Blob([report], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `report-${new Date().toISOString().slice(0, 10)}.txt`; a.click(); URL.revokeObjectURL(url); } // Drag and drop function setupDragAndDrop() { const leftBlock = document.querySelector('.file-block[data-side="left"]'); const rightBlock = document.querySelector('.file-block[data-side="right"]'); [leftBlock, rightBlock].forEach((block, idx) => { const side = idx === 0 ? 'left' : 'right'; block.addEventListener('dragover', (e) => { e.preventDefault(); block.classList.add('drag-over'); }); block.addEventListener('dragleave', () => { block.classList.remove('drag-over'); }); block.addEventListener('drop', async (e) => { e.preventDefault(); block.classList.remove('drag-over'); const file = e.dataTransfer.files[0]; if (!file) return; if (file.size > MAX_SIZE_BYTES) { alert(`File too large (${fileSizeHuman(file.size)})`); return; } try { const text = await file.text(); state.files[side] = { name: file.name, size: file.size, text }; updateSummary(side); compute(); } catch (err) { alert('Failed to read file'); } }); }); } // Keyboard shortcuts function setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { // Ctrl/Cmd + F for search focus if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); els.searchInput?.focus(); } // Ctrl/Cmd + S for export if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); exportDiff('html'); } // F3 or Enter in search to next result if (e.key === 'F3' || (e.key === 'Enter' && document.activeElement === els.searchInput)) { e.preventDefault(); navigateSearch(e.shiftKey ? -1 : 1); } // Alt + Arrow keys for change navigation if (e.altKey && e.key === 'ArrowDown') { e.preventDefault(); navigateChanges(1); } if (e.altKey && e.key === 'ArrowUp') { e.preventDefault(); navigateChanges(-1); } // Escape to close modal or clear search if (e.key === 'Escape') { if (!els.modalOverlay.classList.contains('hidden')) { els.modalOverlay.classList.add('hidden'); } else { clearSearch(); els.searchInput.value = ''; } } }); } // Dark mode toggle function toggleDarkMode() { state.options.darkMode = !state.options.darkMode; document.documentElement.setAttribute('data-theme', state.options.darkMode ? 'dark' : 'light'); // Update CSS variables for dark mode if (state.options.darkMode) { document.documentElement.style.setProperty('--color-bg', '#0f172a'); document.documentElement.style.setProperty('--color-bg-soft', '#1e293b'); document.documentElement.style.setProperty('--color-text', '#f1f5f9'); document.documentElement.style.setProperty('--color-muted', '#94a3b8'); document.documentElement.style.setProperty('--color-border', '#334155'); } else { document.documentElement.style.setProperty('--color-bg', '#ffffff'); document.documentElement.style.setProperty('--color-bg-soft', '#f8fafc'); document.documentElement.style.setProperty('--color-text', '#0f172a'); document.documentElement.style.setProperty('--color-muted', '#475569'); document.documentElement.style.setProperty('--color-border', '#e2e8f0'); } } // View mode switching function setViewMode(mode) { state.options.viewMode = mode; const container = document.querySelector('.diff-container'); if (mode === 'inline' || mode === 'unified') { container.classList.add('unified-view'); document.getElementById('paneRight').style.display = 'none'; document.querySelector('.line-connectors').style.display = 'none'; } else { container.classList.remove('unified-view'); document.getElementById('paneRight').style.display = 'flex'; document.querySelector('.line-connectors').style.display = 'block'; } // Re-render if we have data if (state.result) compute(); } // 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; }); // Search els.searchBtn?.addEventListener('click', () => performSearch(els.searchInput.value)); els.searchInput?.addEventListener('input', (e) => { if (e.target.value.length > 2) performSearch(e.target.value); else if (e.target.value === '') clearSearch(); }); // Toolbar buttons els.wordDiffBtn?.addEventListener('click', () => { state.options.wordDiff = !state.options.wordDiff; els.wordDiffBtn.classList.toggle('active', state.options.wordDiff); if (state.result) compute(); }); els.layoutBtn?.addEventListener('click', () => { const modes = ['side-by-side', 'inline', 'unified']; const currentIdx = modes.indexOf(state.options.viewMode); const nextMode = modes[(currentIdx + 1) % modes.length]; setViewMode(nextMode); }); els.modeBtns?.forEach(btn => { btn.addEventListener('click', () => { els.modeBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); setViewMode(btn.dataset.mode); }); }); // Feature toggles - check if elements exist before adding listeners const toggleHandler = (btn, stateKey, callback) => { if (!btn) return; btn.addEventListener('click', () => { state[stateKey] = !state[stateKey]; btn.classList.toggle('active', state[stateKey]); if (callback) callback(state[stateKey]); }); }; toggleHandler(els.toggleHeatmapBtn, 'showHeatmap', (show) => { els.analyticsWrap.hidden = !show; if (show && state.result) renderHeatmap(state.result.aRes); }); toggleHandler(els.toggleMinimapBtn, 'showMinimap', (show) => { if (els.minimap) els.minimap.hidden = !show; if (show && state.result) renderMinimap(state.result.aRes, state.result.bRes); }); toggleHandler(els.toggleStructureBtn, 'showStructure', (show) => { document.body.classList.toggle('structure-mode', show); }); toggleHandler(els.toggleSemanticsBtn, 'showSemantics', (show) => { document.body.classList.toggle('semantic-mode', show); }); // Export and report els.exportBtn?.addEventListener('click', () => { const format = confirm('Export as HTML? (Cancel for unified diff format)') ? 'html' : 'diff'; exportDiff(format); }); els.reportBtn?.addEventListener('click', generateReport); els.acceptAllBtn?.addEventListener('click', () => { document.querySelectorAll('.change-row').forEach(row => { row.style.opacity = '0.5'; }); }); els.rejectAllBtn?.addEventListener('click', () => { document.querySelectorAll('.change-row').forEach(row => { row.style.display = 'none'; }); }); // Pane action buttons els.acceptLeftBtn?.addEventListener('click', () => { document.querySelectorAll('#tbodyLeft .change-row').forEach(row => row.style.opacity = '0.5'); }); els.rejectLeftBtn?.addEventListener('click', () => { document.querySelectorAll('#tbodyLeft .change-row').forEach(row => row.style.display = 'none'); }); els.acceptRightBtn?.addEventListener('click', () => { document.querySelectorAll('#tbodyRight .change-row').forEach(row => row.style.opacity = '0.5'); }); els.rejectRightBtn?.addEventListener('click', () => { document.querySelectorAll('#tbodyRight .change-row').forEach(row => row.style.display = 'none'); }); els.minimapMode?.addEventListener('change', (e) => { if (state.result) renderMinimap(state.result.aRes, state.result.bRes); }); // Analytics panel toggles (mirror the toolbar ones) els.analyticsToggleHeatmap?.addEventListener('click', () => { els.toggleHeatmapBtn?.click(); }); els.analyticsToggleMinimap?.addEventListener('click', () => { els.toggleMinimapBtn?.click(); }); els.analyticsToggleStructure?.addEventListener('click', () => { els.toggleStructureBtn?.click(); }); // Modal els.infoBtn?.addEventListener('click', () => { els.modalOverlay.classList.remove('hidden'); }); els.modalClose?.addEventListener('click', () => { els.modalOverlay.classList.add('hidden'); }); els.modalOverlay?.addEventListener('click', (e) => { if (e.target === els.modalOverlay) els.modalOverlay.classList.add('hidden'); }); // Initial setup setupSyncScrolling(); setupDragAndDrop(); setupKeyboardShortcuts(); // Add dark mode toggle to header if not already present if (!document.getElementById('darkModeBtn')) { const darkModeBtn = document.createElement('button'); darkModeBtn.id = 'darkModeBtn'; darkModeBtn.className = 'btn-ghost'; darkModeBtn.innerHTML = '🌙'; darkModeBtn.title = 'Toggle Dark Mode'; darkModeBtn.addEventListener('click', toggleDarkMode); const viewControls = document.querySelector('.view-controls'); if (viewControls) viewControls.appendChild(darkModeBtn); } } // Init function init() { wireEvents(); // Initial blank updateSummary('left'); updateSummary('right'); // Show initial empty state if (!state.files.left.text && !state.files.right.text) { els.tbodyLeft.innerHTML = 'Load File A to start comparison'; els.tbodyRight.innerHTML = 'Load File B to start comparison'; } } // Expose state for debugging window.DiffLens = { state, els }; document.addEventListener('DOMContentLoaded', init); })();