Commit 981784ff authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat(diff): real line-by-line diff engine with jsdiff library

- Add jsdiff v7.0.0 via CDN for Myers diff algorithm
- Side-by-Side view: dual pane with synced scrolling, line numbers
- Unified view: single pane with dual line number columns
- Color coded: green=added, red=removed, white=same, striped=empty
- Summary stats bar: identical/added/removed counts + char counts
- View mode toggle: switch between side-by-side and unified
- Replace old raw text dump with real diff computation
parent 70a7b700
......@@ -8,6 +8,7 @@
<link rel="stylesheet" href="/static/common/components.css">
<link rel="stylesheet" href="/static/manage_prompt/manage_prompt.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/diff@7.0.0/dist/diff.min.js"></script>
</head>
<body>
<div class="shell" id="app">
......@@ -88,24 +89,29 @@
<i data-lucide="git-compare" class="icon-md text-primary"></i>
<h3 id="diff-title">So sánh Dev ↔ Production</h3>
</div>
<button class="modal-close" onclick="closeDiffModal()"><i data-lucide="x" class="icon-sm"></i></button>
</div>
<div class="modal-body bg-dark">
<div class="diff-wrapper">
<div class="diff-box">
<div class="diff-header dev-header">
<span class="diff-header-title"><i data-lucide="terminal" class="icon-sm"></i> DEV (Local)</span>
</div>
<pre class="diff-content" id="diff-dev"></pre>
</div>
<div class="diff-box">
<div class="diff-header prod-header">
<span class="diff-header-title"><i data-lucide="cloud" class="icon-sm"></i> PRODUCTION (Langfuse)</span>
</div>
<pre class="diff-content" id="diff-prod"></pre>
<div style="display:flex;align-items:center;gap:8px">
<div class="diff-view-toggle">
<button class="diff-toggle-btn active" id="btn-side" onclick="setDiffView('side')">
<i data-lucide="columns-2" class="icon-xs"></i> Side-by-Side
</button>
<button class="diff-toggle-btn" id="btn-unified" onclick="setDiffView('unified')">
<i data-lucide="align-left" class="icon-xs"></i> Unified
</button>
</div>
<button class="modal-close" onclick="closeDiffModal()"><i data-lucide="x" class="icon-sm"></i></button>
</div>
</div>
<!-- Summary Stats -->
<div class="diff-summary" id="diff-summary">
<span class="diff-stat diff-stat-same"><i data-lucide="equal" class="icon-xs"></i> <strong id="ds-same">0</strong> giống</span>
<span class="diff-stat diff-stat-added"><i data-lucide="plus" class="icon-xs"></i> <strong id="ds-added">0</strong> thêm</span>
<span class="diff-stat diff-stat-removed"><i data-lucide="minus" class="icon-xs"></i> <strong id="ds-removed">0</strong> xóa</span>
<span class="diff-stat diff-stat-chars"><i data-lucide="type" class="icon-xs"></i> DEV: <strong id="ds-dev-chars">0</strong> · PROD: <strong id="ds-prod-chars">0</strong> chars</span>
</div>
<!-- Diff Output -->
<div class="modal-body bg-dark" style="padding:0;">
<div id="diff-output" class="diff-output-side"></div>
</div>
<div class="modal-foot">
<button class="detail-btn outline" onclick="closeDiffModal()">Đóng</button>
<button class="detail-btn primary" id="btn-promote-modal" onclick="promoteFromModal()">
......
......@@ -679,3 +679,198 @@ body {
.body { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
.panel-left, .panel-right { border: none; border-bottom: 1px solid var(--b); max-height: 300px; }
}
/* ═══════════════════════════════════════════════════════════
DIFF VIEWER — Line-by-line comparison (jsdiff powered)
═══════════════════════════════════════════════════════════ */
/* ── Toggle Buttons ── */
.diff-view-toggle {
display: flex;
background: var(--bg);
border: 1px solid var(--b);
border-radius: 8px;
overflow: hidden;
}
.diff-toggle-btn {
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
font-family: var(--font-sans);
border: none;
background: transparent;
color: var(--m);
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all .2s;
}
.diff-toggle-btn:hover { color: var(--t); background: rgba(0,0,0,0.03); }
.diff-toggle-btn.active {
background: var(--primary);
color: #fff;
}
/* ── Summary Stats Bar ── */
.diff-summary {
display: flex;
align-items: center;
gap: 20px;
padding: 10px 24px;
border-bottom: 1px solid var(--b);
background: var(--bg);
font-size: 13px;
color: var(--m);
flex-wrap: wrap;
}
.diff-stat {
display: flex;
align-items: center;
gap: 5px;
font-weight: 500;
}
.diff-stat strong { font-weight: 700; }
.diff-stat-same strong { color: var(--t); }
.diff-stat-added strong { color: #16a34a; }
.diff-stat-removed strong { color: #dc2626; }
.diff-stat-chars strong { color: var(--primary); }
/* ── Side-by-Side Layout ── */
.diff-output-side {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100%;
min-height: 0;
}
.diff-pane {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.diff-pane:first-child { border-right: 1px solid var(--b); }
.diff-pane-header {
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid var(--b);
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.diff-pane-body {
flex: 1;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1;
}
/* ── Unified Layout ── */
.diff-output-unified {
display: flex;
flex-direction: column;
height: 100%;
}
.diff-unified-header {
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid var(--b);
background: var(--bg);
display: flex;
align-items: center;
gap: 6px;
}
.diff-unified-body {
flex: 1;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1;
}
/* ── Diff Lines ── */
.diff-line {
display: flex;
align-items: stretch;
min-height: 28px;
border-bottom: 1px solid rgba(0,0,0,0.04);
}
.diff-ln {
width: 44px;
min-width: 44px;
padding: 5px 8px;
text-align: right;
font-size: 11px;
color: var(--m);
background: rgba(0,0,0,0.02);
border-right: 1px solid var(--b);
user-select: none;
display: flex;
align-items: center;
justify-content: flex-end;
}
.diff-marker {
width: 22px;
min-width: 22px;
padding: 5px 0;
text-align: center;
font-weight: 700;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.diff-text {
flex: 1;
padding: 5px 12px;
white-space: pre-wrap;
word-break: break-word;
display: flex;
align-items: center;
}
/* ── Line Type Colors ── */
.diff-same {
background: var(--s);
}
.diff-same .diff-text { color: var(--t); }
.diff-added {
background: #ecfdf5;
}
.diff-added .diff-ln { background: #dcfce7; color: #16a34a; }
.diff-added .diff-marker { color: #16a34a; }
.diff-added .diff-text { color: #15803d; }
.diff-removed {
background: #fef2f2;
}
.diff-removed .diff-ln { background: #fee2e2; color: #dc2626; }
.diff-removed .diff-marker { color: #dc2626; }
.diff-removed .diff-text { color: #b91c1c; }
.diff-empty {
background: #f9fafb;
}
.diff-empty .diff-ln { background: #f3f4f6; }
.diff-empty .diff-text {
background: repeating-linear-gradient(
45deg,
transparent,
transparent 4px,
rgba(0,0,0,0.03) 4px,
rgba(0,0,0,0.03) 8px
);
}
/* ── Scrollbar inside diff panes ── */
.diff-pane-body::-webkit-scrollbar,
.diff-unified-body::-webkit-scrollbar { width: 6px; }
.diff-pane-body::-webkit-scrollbar-thumb,
.diff-unified-body::-webkit-scrollbar-thumb { background: var(--input); border-radius: 999px; }
......@@ -348,7 +348,10 @@ async function syncToLangfuse(versionId, moduleName) {
}
}
// ═══ DIFF MODAL ════════════════════════════════════════════
// ═══ DIFF ENGINE (jsdiff-powered) ══════════════════════════
let diffViewMode = 'side'; // 'side' | 'unified'
let lastDiffData = null; // cache for view toggle
async function openDiffModal(moduleName) {
const mod = modules.find(m => m.module_name === moduleName);
......@@ -365,13 +368,185 @@ async function openDiffModal(moduleName) {
fetchContent(mod.production.id),
]);
document.getElementById('diff-dev').textContent = devC || '(empty)';
document.getElementById('diff-prod').textContent = prodC || '(empty)';
const devText = devC || '';
const prodText = prodC || '';
// Compute diff using jsdiff
const changes = Diff.diffLines(devText, prodText);
lastDiffData = { changes, devText, prodText, devV: mod.dev.version, prodV: mod.production.version };
// Update char counts
document.getElementById('ds-dev-chars').textContent = devText.length.toLocaleString();
document.getElementById('ds-prod-chars').textContent = prodText.length.toLocaleString();
renderDiff();
document.getElementById('diff-modal').classList.add('open');
lucide.createIcons();
}
function setDiffView(mode) {
diffViewMode = mode;
document.getElementById('btn-side').classList.toggle('active', mode === 'side');
document.getElementById('btn-unified').classList.toggle('active', mode === 'unified');
if (lastDiffData) renderDiff();
}
function renderDiff() {
const { changes, devV, prodV } = lastDiffData;
const container = document.getElementById('diff-output');
if (diffViewMode === 'side') {
container.className = 'diff-output-side';
renderSideBySide(container, changes, devV, prodV);
} else {
container.className = 'diff-output-unified';
renderUnified(container, changes, devV, prodV);
}
lucide.createIcons();
}
// ── Side-by-Side Renderer ──
function renderSideBySide(container, changes, devV, prodV) {
let leftLines = [];
let rightLines = [];
let leftNum = 0, rightNum = 0;
let stats = { same: 0, added: 0, removed: 0 };
changes.forEach(part => {
const lines = part.value.replace(/\n$/, '').split('\n');
if (!part.added && !part.removed) {
// Unchanged
lines.forEach(line => {
leftNum++; rightNum++;
leftLines.push({ num: leftNum, text: line, type: 'same' });
rightLines.push({ num: rightNum, text: line, type: 'same' });
stats.same++;
});
} else if (part.removed) {
// Only in DEV
lines.forEach(line => {
leftNum++;
leftLines.push({ num: leftNum, text: line, type: 'removed' });
rightLines.push({ num: '', text: '', type: 'empty' });
stats.removed++;
});
} else if (part.added) {
// Only in PROD
lines.forEach(line => {
rightNum++;
leftLines.push({ num: '', text: '', type: 'empty' });
rightLines.push({ num: rightNum, text: line, type: 'added' });
stats.added++;
});
}
});
updateStats(stats);
const leftHtml = leftLines.map(l => buildLine(l)).join('');
const rightHtml = rightLines.map(l => buildLine(l)).join('');
container.innerHTML = `
<div class="diff-pane">
<div class="diff-pane-header dev-header">
<span class="diff-header-title"><i data-lucide="terminal" class="icon-sm"></i> DEV (Local) — v${devV}</span>
</div>
<div class="diff-pane-body">${leftHtml}</div>
</div>
<div class="diff-pane">
<div class="diff-pane-header prod-header">
<span class="diff-header-title"><i data-lucide="cloud" class="icon-sm"></i> PRODUCTION — v${prodV}</span>
</div>
<div class="diff-pane-body">${rightHtml}</div>
</div>
`;
// Sync scroll
const panes = container.querySelectorAll('.diff-pane-body');
if (panes.length === 2) {
panes[0].addEventListener('scroll', () => { panes[1].scrollTop = panes[0].scrollTop; });
panes[1].addEventListener('scroll', () => { panes[0].scrollTop = panes[1].scrollTop; });
}
}
// ── Unified Renderer ──
function renderUnified(container, changes, devV, prodV) {
let html = '';
let leftNum = 0, rightNum = 0;
let stats = { same: 0, added: 0, removed: 0 };
changes.forEach(part => {
const lines = part.value.replace(/\n$/, '').split('\n');
lines.forEach(line => {
if (!part.added && !part.removed) {
leftNum++; rightNum++;
html += `<div class="diff-line diff-same">
<span class="diff-ln">${leftNum}</span>
<span class="diff-ln">${rightNum}</span>
<span class="diff-marker"> </span>
<span class="diff-text">${escapeHtml(line)}</span>
</div>`;
stats.same++;
} else if (part.removed) {
leftNum++;
html += `<div class="diff-line diff-removed">
<span class="diff-ln">${leftNum}</span>
<span class="diff-ln"></span>
<span class="diff-marker">−</span>
<span class="diff-text">${escapeHtml(line)}</span>
</div>`;
stats.removed++;
} else if (part.added) {
rightNum++;
html += `<div class="diff-line diff-added">
<span class="diff-ln"></span>
<span class="diff-ln">${rightNum}</span>
<span class="diff-marker">+</span>
<span class="diff-text">${escapeHtml(line)}</span>
</div>`;
stats.added++;
}
});
});
updateStats(stats);
container.innerHTML = `
<div class="diff-unified-header">
<span class="diff-header-title"><i data-lucide="git-compare" class="icon-sm"></i> DEV v${devV} ↔ PROD v${prodV}</span>
</div>
<div class="diff-unified-body">${html}</div>
`;
}
function buildLine(l) {
if (l.type === 'empty') {
return `<div class="diff-line diff-empty">
<span class="diff-ln"></span>
<span class="diff-text"></span>
</div>`;
}
const cls = l.type === 'removed' ? 'diff-removed' : l.type === 'added' ? 'diff-added' : 'diff-same';
const marker = l.type === 'removed' ? '−' : l.type === 'added' ? '+' : ' ';
return `<div class="diff-line ${cls}">
<span class="diff-ln">${l.num}</span>
<span class="diff-marker">${marker}</span>
<span class="diff-text">${escapeHtml(l.text)}</span>
</div>`;
}
function updateStats(stats) {
document.getElementById('ds-same').textContent = stats.same;
document.getElementById('ds-added').textContent = stats.added;
document.getElementById('ds-removed').textContent = stats.removed;
}
function closeDiffModal() {
document.getElementById('diff-modal').classList.remove('open');
lastDiffData = null;
}
// ═══ EDITOR MODAL ══════════════════════════════════════════
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment