// 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 = `
Modified
${stats.modified}
`;
// 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 ``;
}).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);
})();