PR-Dojo/src/components/DiffViewer.astro

547 lines
18 KiB
Text
Raw Normal View History

2026-04-16 19:44:23 +02:00
---
import { runHighlighterWithAstro } from '../../node_modules/@astrojs/prism/dist/highlighter.js';
2026-04-16 19:44:23 +02:00
interface Props {
code: string;
hints: string[];
file?: string;
2026-04-16 19:44:23 +02:00
}
const { code, hints, file } = Astro.props;
const id = `dv-${Math.random().toString(36).substring(2, 9)}`;
2026-04-16 19:44:23 +02:00
const originalLines = code.split('\n');
const fileExtension = (file?.split('/').pop() || 'file').split('.').pop() || '';
const langMap: Record<string, string> = {
js: 'javascript', jsx: 'jsx', ts: 'typescript', tsx: 'tsx',
html: 'html', css: 'css', json: 'json', py: 'python',
rb: 'ruby', java: 'java', c: 'c', cpp: 'cpp', cs: 'csharp',
go: 'go', rs: 'rust', php: 'php', swift: 'swift', kt: 'kotlin',
sh: 'bash', yaml: 'yaml', yml: 'yaml', md: 'markdown', sql: 'sql',
xml: 'xml', dockerfile: 'dockerfile', gitignore: 'bash', astro: 'astro',
};
const language = langMap[fileExtension] || undefined;
let highlightedLines: string[] = [];
if (language) {
try {
const { html } = runHighlighterWithAstro(language, code);
const parsed = html.split('\n');
if (parsed.length > originalLines.length && parsed[parsed.length - 1] === '') {
parsed.pop();
}
highlightedLines = parsed.length === originalLines.length ? parsed : [];
} catch {
// Fallback: no highlighting
}
}
const highlightedMap = new Map<string, string>();
highlightedLines.forEach((hl, i) => {
highlightedMap.set(String(i + 1), hl);
});
const lineContentMap = new Map<string, string>();
originalLines.forEach((line, i) => {
const lineNum = String(i + 1);
lineContentMap.set(lineNum, highlightedMap.get(lineNum) || line);
});
2026-04-16 19:44:23 +02:00
---
<div id={id} class="diff-viewer-container" data-dv-id={id} data-original-code={code} data-file-extension={fileExtension}>
<div class="bg-[#0d1117] border border-[#30363d] rounded-md overflow-hidden">
<!-- Toolbar -->
<div class="bg-[#161b22] px-4 py-3 border-b border-[#30363d] flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm text-[#8b949e]">{file?.split('/').pop() || 'file'}.{fileExtension}</span>
<span class="text-xs text-[#484f58]">|</span>
<span class="text-xs text-[#8b949e]">
<span id="left-lines-count">{originalLines.length}</span> lines
</span>
</div>
<div class="flex items-center gap-2">
<button id="reveal-hints-btn" class="text-xs px-2 py-1 rounded bg-[#21262d] text-[#8b949e] hover:text-[#c9d1d9] hover:bg-[#30363d] transition-colors focus:outline-none focus:ring-0">
Reveal Hints
</button>
<button id="reset-btn" class="text-xs px-2 py-1 rounded bg-[#21262d] text-[#8b949e] hover:text-[#c9d1d9] hover:bg-[#30363d] transition-colors focus:outline-none focus:ring-0">
Reset
</button>
</div>
2026-04-16 19:44:23 +02:00
</div>
<!-- Side-by-side viewer -->
<div class="flex flex-col md:flex-row min-h-[400px]">
<!-- Left panel: Original code -->
<div class="flex-1 flex flex-col border-b md:border-b-0 md:border-r border-[#30363d]">
<div class="bg-[#161b22] px-4 py-2 border-b border-[#30363d] flex items-center justify-between">
<span class="text-xs font-semibold text-[#f85149] uppercase tracking-wider">Original (Buggy)</span>
<span class="text-xs text-[#484f58]">Read-only</span>
</div>
<div class="flex-1 overflow-auto relative">
<div class="flex min-h-full">
<!-- Line numbers -->
<div class="left-gutter select-none bg-[#0d1117] border-r border-[#30363d] py-2 sticky left-0 z-10">
{originalLines.map((_, index) => {
const lineNum = index + 1;
const hasHint = hints.some(h => h.includes(`Line ${lineNum}`));
return (
<div
data-line={lineNum}
data-side="left"
class={`line-gutter-row font-mono text-right pr-3 pl-3 text-[#484f58] text-sm cursor-pointer hover:text-[#8b949e] transition-colors leading-[1.6] ${hasHint ? 'has-hint' : ''}`}
title={`Mark line ${lineNum} as buggy`}
>
{lineNum}
</div>
);
})}
</div>
<!-- Code content -->
<div class="left-code-area flex-1 py-2 overflow-hidden">
{originalLines.map((line, index) => {
const lineNum = index + 1;
const hasHint = hints.some(h => h.includes(`Line ${lineNum}`));
const hintText = hints.find(h => h.includes(`Line ${lineNum}`));
const content = highlightedMap.get(String(lineNum)) || line || ' ';
return (
<div
data-line={lineNum}
data-side="left"
class={`code-row flex items-start gap-0 px-4 text-sm leading-[1.6] transition-colors ${hasHint ? 'has-hint' : ''}`}
>
<span class={`text-[#484f58] w-0 select-none ${hasHint ? 'has-hint-num' : ''}`}></span>
<span class={`code-text font-mono text-[#e6edf3] whitespace-pre ${hasHint ? 'has-hint-bg' : ''}`} set:html={content}></span>
{hasHint && (
<span class="hint-text font-mono text-[#79c0ff] text-xs ml-2 hidden">
{hintText?.replace(`Line ${lineNum}: `, '')}
</span>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
<!-- Right panel: Editable code -->
<div class="flex-1 flex flex-col">
<div class="bg-[#161b22] px-4 py-2 border-b border-[#30363d] flex items-center justify-between">
<span class="text-xs font-semibold text-[#3fb950] uppercase tracking-wider">Your Fix</span>
<span class="text-xs text-[#484f58]">Editable</span>
</div>
<div class="flex-1 overflow-auto relative">
<div class="flex min-h-full">
<!-- Line numbers -->
<div class="right-gutter select-none bg-[#0d1117] border-r border-[#30363d] py-2 sticky left-0 z-10">
{originalLines.map((_, index) => (
<div
data-line={index + 1}
data-side="right"
class="right-gutter-row font-mono text-right pr-3 pl-3 text-[#484f58] text-sm leading-[1.6] bg-transparent"
>
{index + 1}
</div>
))}
</div>
<!-- Editable content with diff overlay -->
<div class="right-editor-area flex-1 relative py-0">
<!-- Diff highlight overlay -->
<div id="diff-highlight" class="absolute inset-0 py-2 pointer-events-none">
{originalLines.map((_, index) => {
const lineNum = index + 1;
return (
<div
data-diff-line={lineNum}
class="diff-row h-[1.6em] px-4 transition-colors"
></div>
);
})}
</div>
<!-- Textarea for editing -->
<textarea
id="edit-textarea"
class="editor-textarea absolute inset-0 w-full h-full bg-transparent text-[#e6edf3] font-mono text-sm leading-[1.6] resize-none focus:outline-none pl-4 pr-4 py-2"
spellcheck="false"
>{code}</textarea>
</div>
</div>
</div>
</div>
</div>
<!-- Status bar -->
<div class="bg-[#161b22] px-4 py-2 border-t border-[#30363d] flex items-center justify-between text-xs">
<div class="flex items-center gap-4">
<span class="text-[#484f58]">
<span id="diff-added" class="text-[#3fb950]">0</span> added
</span>
<span class="text-[#484f58]">
<span id="diff-removed" class="text-[#f85149]">0</span> removed
</span>
<span class="text-[#484f58]">
<span id="diff-changed" class="text-[#d29922]">0</span> changed
</span>
</div>
<div class="text-[#484f58]">
Click lines on the left to mark as buggy
</div>
</div>
</div>
2026-04-16 19:44:23 +02:00
</div>
<style>
.left-gutter-row,
.right-gutter-row {
height: 1.6em;
}
.code-row {
height: 1.6em;
min-height: 1.6em;
}
.editor-textarea {
2026-04-16 19:44:23 +02:00
line-height: 1.6;
tab-size: 2;
-moz-tab-size: 2;
2026-04-16 19:44:23 +02:00
}
.diff-row {
height: 1.6em;
min-height: 1.6em;
}
.diff-row.added {
background-color: #3fb95018;
}
.diff-row.removed {
background-color: #f8514918;
}
.code-row.diff-removed {
background-color: #f8514918;
2026-04-16 19:44:23 +02:00
}
.code-row.diff-added {
background-color: #3fb95018;
2026-04-16 19:44:23 +02:00
}
.diff-viewer-container:not(.revealed) .has-hint .code-text {
background-color: transparent !important;
}
.diff-viewer-container.revealed .has-hint .hint-text {
display: inline;
}
.has-hint .code-text {
background-color: #f8514918;
}
.has-hint.has-hint-bg .code-text {
background-color: #f8514918;
}
.has-hint .has-hint-num {
color: #f85149 !important;
}
.has-hint:not(.revealed) .has-hint-num {
color: #484f58 !important;
}
.diff-viewer-container.revealed .has-hint .has-hint-num {
color: #f85149 !important;
}
/* Scrollbar styling */
.overflow-auto::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.overflow-auto::-webkit-scrollbar-track {
background: #0d1117;
}
.overflow-auto::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
.overflow-auto::-webkit-scrollbar-thumb:hover {
background: #484f58;
}
2026-04-16 19:44:23 +02:00
</style>
<script client:load>
(() => {
const container = document.querySelector('[data-dv-id]');
if (!container) return;
const originalCode = container.getAttribute('data-original-code') || '';
const originalLines = originalCode.split('\n');
const leftGutter = container.querySelector('.left-gutter');
const leftCodeArea = container.querySelector('.left-code-area');
const rightGutter = container.querySelector('.right-gutter');
const rightEditorArea = container.querySelector('.right-editor-area');
const textarea = document.getElementById('edit-textarea');
const diffHighlight = document.getElementById('diff-highlight');
const revealBtn = document.getElementById('reveal-hints-btn');
const resetBtn = document.getElementById('reset-btn');
const addedEl = document.getElementById('diff-added');
const removedEl = document.getElementById('diff-removed');
const changedEl = document.getElementById('diff-changed');
const buggedLines = new Set();
// Synchronized scrolling
function syncScroll(source, targetGutter, targetCode) {
if (targetCode) {
targetCode.scrollTop = source.scrollTop;
targetCode.scrollLeft = source.scrollLeft;
}
if (targetGutter) {
targetGutter.scrollTop = source.scrollTop;
}
}
// Left side scroll sync (also scrolls right side)
if (leftCodeArea) {
leftCodeArea.addEventListener('scroll', () => {
syncScroll(leftCodeArea, rightGutter, rightEditorArea);
});
}
// Right side scroll sync (also scrolls left side)
if (rightEditorArea) {
rightEditorArea.addEventListener('scroll', () => {
syncScroll(rightEditorArea, leftGutter, leftCodeArea);
});
}
// Diff computation using LCS-based line diff
function computeDiff(oldText, newText) {
const oldLines = oldText.split('\n');
const newLines = newText.split('\n');
// LCS to compute diff
const m = oldLines.length;
const n = newLines.length;
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack to get diff ops
const ops = [];
let i = m, j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
ops.push({ type: 'equal', oldLine: i, newLine: j });
i--; j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
ops.push({ type: 'added', newLine: j });
j--;
} else {
ops.push({ type: 'removed', oldLine: i });
i--;
}
}
ops.reverse();
return ops;
}
function updateDiffHighlight() {
if (!textarea || !diffHighlight) return;
const newText = textarea.value;
const allOld = originalLines;
const allNew = newText.split('\n');
const totalLines = Math.max(allOld.length, allNew.length);
let added = 0, removed = 0, changed = 0;
// Rebuild diff rows to match textarea line count
diffHighlight.innerHTML = '';
for (let i = 0; i < totalLines; i++) {
const row = document.createElement('div');
row.setAttribute('data-diff-line', String(i + 1));
row.className = 'diff-row h-[1.6em] px-4 transition-colors';
diffHighlight.appendChild(row);
}
// Compare line by line using LCS-based approach
let oi = 0, ni = 0;
const statusMap = new Map(); // old line index -> 'equal' | 'removed' | 'changed'
while (oi < allOld.length || ni < allNew.length) {
if (oi < allOld.length && ni < allNew.length && allOld[oi] === allNew[ni]) {
statusMap.set(oi, 'equal');
oi++;
ni++;
} else if (oi < allOld.length && ni < allNew.length) {
statusMap.set(oi, 'changed');
added++;
changed++;
oi++;
ni++;
} else if (oi < allOld.length) {
statusMap.set(oi, 'removed');
removed++;
oi++;
} else {
added++;
ni++;
}
}
// Apply to right side diff overlay
statusMap.forEach((status, idx) => {
const lineNum = idx + 1;
const row = diffHighlight.querySelector(`[data-diff-line="${lineNum}"]`);
if (row) {
if (status === 'removed') {
row.classList.add('removed');
} else if (status === 'changed') {
row.classList.add('added');
}
}
});
// Apply to left panel code rows (only removed = red, no yellow)
statusMap.forEach((status, idx) => {
const lineNum = idx + 1;
const row = leftCodeArea?.querySelector(`.code-row[data-line="${lineNum}"]`);
if (row) {
row.classList.remove('diff-removed', 'diff-added');
if (status === 'removed') {
row.classList.add('diff-removed');
}
}
});
// Highlight added lines beyond original length
for (let i = allOld.length; i < allNew.length; i++) {
const row = diffHighlight.querySelector(`[data-diff-line="${i + 1}"]`);
if (row) {
row.classList.add('added');
}
}
addedEl.textContent = added;
removedEl.textContent = removed;
changedEl.textContent = changed;
}
// Left panel: click to mark bugged lines (gutter + code area)
function toggleBuggedLine(lineNum) {
if (isNaN(lineNum) || lineNum === 0) return;
const gutterRow = leftGutter?.querySelector(`[data-line="${lineNum}"][data-side="left"]`);
const codeRow = leftCodeArea?.querySelector(`.code-row[data-line="${lineNum}"]`);
const isBugged = buggedLines.has(lineNum);
if (isBugged) {
buggedLines.delete(lineNum);
if (gutterRow) { gutterRow.style.backgroundColor = ''; gutterRow.style.borderLeft = ''; }
if (codeRow) { codeRow.style.backgroundColor = ''; }
} else {
buggedLines.add(lineNum);
if (gutterRow) { gutterRow.style.backgroundColor = '#f8514920'; gutterRow.style.borderLeft = '3px solid #f85149'; }
if (codeRow) { codeRow.style.backgroundColor = '#f8514920'; }
}
container.dispatchEvent(new CustomEvent('bugged-line-toggle', {
bubbles: true,
detail: { line: lineNum, bugged: buggedLines.has(lineNum), allBugged: [...buggedLines] }
}));
}
leftGutter?.addEventListener('click', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const row = target.closest('[data-line][data-side="left"]');
if (!row) return;
const lineNum = parseInt(row.getAttribute('data-line') || '0', 10);
toggleBuggedLine(lineNum);
});
leftCodeArea?.addEventListener('click', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const row = target.closest('.code-row');
if (!row) return;
const lineNum = parseInt(row.getAttribute('data-line') || '0', 10);
toggleBuggedLine(lineNum);
});
// Reveal hints
revealBtn?.addEventListener('click', () => {
const isRevealed = container.classList.toggle('revealed');
revealBtn.textContent = isRevealed ? 'Hide Hints' : 'Reveal Hints';
revealBtn.classList.toggle('text-[#79c0ff]', isRevealed);
});
// Reset
resetBtn?.addEventListener('click', () => {
textarea.value = originalLines.join('\n');
buggedLines.clear();
container.classList.remove('revealed');
revealBtn.textContent = 'Reveal Hints';
revealBtn.classList.remove('text-[#79c0ff]');
const gutterRows = leftGutter?.querySelectorAll('[data-side="left"]');
gutterRows?.forEach(row => {
row.style.backgroundColor = '';
row.style.borderLeft = '';
});
const codeRows = leftCodeArea?.querySelectorAll('.code-row');
codeRows?.forEach(row => {
row.style.backgroundColor = '';
row.classList.remove('diff-removed', 'diff-added');
});
updateDiffHighlight();
container.dispatchEvent(new CustomEvent('bugged-line-reset', {
bubbles: true,
detail: { allBugged: [] }
}));
});
// Textarea input -> update diff
textarea?.addEventListener('input', () => {
updateDiffHighlight();
});
// Tab key support in textarea
textarea?.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 2;
updateDiffHighlight();
}
});
// Initial diff
updateDiffHighlight();
})();
</script>