feat: side-by-side diff view + auto-save skips backup
CI / lint (push) Failing after 7s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 10s

- Side-by-side diff: toggle button (columns-2/align-justify icon) in toolbar
  switches between unified and side-by-side view
- Backend: diff endpoint returns left_content + right_content for side-by-side
- Frontend: SequenceMatcher-based LCS diff for two-column rendering
- CSS: .backup-diff-sidebyside with left/right borders, empty cell styling
- Auto-save: PUT /save?backup=false skips backup creation (auto-save=no backup)
- Footer text updated to reflect backup-on-manual-save behavior
This commit is contained in:
2026-06-04 18:37:14 -04:00
parent 1e9ecf656f
commit 8c5a5fd6b2
4 changed files with 177 additions and 5 deletions
+128 -2
View File
@@ -32,6 +32,9 @@ const state = {
leftVersion: null,
rightVersion: null,
diffText: '',
diffLeft: '',
diffRight: '',
diffMode: 'unified', // 'unified' or 'sidebyside'
};
// ---------------------------------------------------------------------------
@@ -208,6 +211,9 @@ function renderMainView() {
<button class="backup-restore-btn" id="backup-restore-btn" title="Restaurer la version de gauche">
<i data-lucide="rotate-ccw" class="icon-sm"></i> Restaurer
</button>
<button class="backup-diff-mode-btn" id="backup-diff-mode-btn" title="Vue côte à côte / unifiée">
<i data-lucide="columns-2" class="icon-sm"></i>
</button>
</div>
<div class="backup-diff-container" id="backup-diff-container">
@@ -217,7 +223,7 @@ function renderMainView() {
</div>
<div class="backup-footer">
<span class="backup-footer-hint">Backups automatiques avant chaque modification</span>
<span class="backup-footer-hint">Backups créés lors de la sauvegarde manuelle (Ctrl+S)</span>
</div>`;
setBody(html);
@@ -246,6 +252,22 @@ function bindEvents() {
restoreBtn?.addEventListener('click', async () => {
await restoreBackup();
});
// Side-by-side toggle
const modeBtn = document.getElementById('backup-diff-mode-btn');
modeBtn?.addEventListener('click', () => {
state.diffMode = state.diffMode === 'sidebyside' ? 'unified' : 'sidebyside';
const container = document.getElementById('backup-diff-container');
if (container) {
container.innerHTML = state.diffMode === 'sidebyside'
? renderDiffSideBySide(state.diffLeft, state.diffRight)
: renderDiff(state.diffText);
}
// Update icon
const icon = modeBtn.querySelector('i');
if (icon) icon.setAttribute('data-lucide', state.diffMode === 'sidebyside' ? 'columns-2' : 'align-justify');
safeCreateIcons();
});
}
// ---------------------------------------------------------------------------
@@ -269,7 +291,11 @@ async function loadDiff() {
try {
const data = await api(url);
state.diffText = data.diff || '';
container.innerHTML = renderDiff(state.diffText);
state.diffLeft = data.left_content || '';
state.diffRight = data.right_content || '';
container.innerHTML = state.diffMode === 'sidebyside'
? renderDiffSideBySide(state.diffLeft, state.diffRight)
: renderDiff(state.diffText);
} catch (err) {
container.innerHTML = `<div class="backup-diff-error">Erreur: ${escapeHtml(err?.detail || err?.message || 'Unknown')}</div>`;
}
@@ -348,6 +374,106 @@ function renderDiff(diffText) {
return html;
}
// ---------------------------------------------------------------------------
// Side-by-side diff rendering
// ---------------------------------------------------------------------------
function renderDiffSideBySide(leftText, rightText) {
if (!leftText && !rightText) {
return '<div class="backup-diff-empty">Aucune différence — les versions sont identiques.</div>';
}
const leftLines = (leftText || '').split('\n');
const rightLines = (rightText || '').split('\n');
// Compute matching opcodes using difflib-style SequenceMatcher
// We build two arrays of line groups: equal, delete (left-only), insert (right-only), replace
const sm = new difflibSeqMatcher(leftLines, rightLines);
const ops = sm.getOpcodes();
let html = '<div class="backup-diff-output backup-diff-sidebyside"><table class="backup-diff-table sidebyside"><colgroup><col style="width:50%"><col style="width:50%"></colgroup><tbody>';
for (const [tag, i1, i2, j1, j2] of ops) {
if (tag === 'equal') {
for (let k = 0; k < (i2 - i1); k++) {
html += `<tr class="diff-context"><td class="diff-content sidebyside-left"><pre>${escapeHtml(leftLines[i1 + k])}</pre></td><td class="diff-content sidebyside-right"><pre>${escapeHtml(rightLines[j1 + k])}</pre></td></tr>`;
}
} else if (tag === 'delete') {
for (let k = i1; k < i2; k++) {
html += `<tr class="diff-del"><td class="diff-content sidebyside-left"><pre>${escapeHtml(leftLines[k])}</pre></td><td class="diff-content sidebyside-right diff-empty-cell"></td></tr>`;
}
} else if (tag === 'insert') {
for (let k = j1; k < j2; k++) {
html += `<tr class="diff-add"><td class="diff-content sidebyside-left diff-empty-cell"></td><td class="diff-content sidebyside-right"><pre>${escapeHtml(rightLines[k])}</pre></td></tr>`;
}
} else if (tag === 'replace') {
const maxLen = Math.max(i2 - i1, j2 - j1);
for (let k = 0; k < maxLen; k++) {
const leftLine = (i1 + k < i2) ? escapeHtml(leftLines[i1 + k]) : '';
const rightLine = (j1 + k < j2) ? escapeHtml(rightLines[j1 + k]) : '';
const leftClass = (i1 + k < i2) ? 'diff-del' : 'diff-empty-cell';
const rightClass = (j1 + k < j2) ? 'diff-add' : 'diff-empty-cell';
html += `<tr><td class="diff-content sidebyside-left ${leftClass}"><pre>${leftLine}</pre></td><td class="diff-content sidebyside-right ${rightClass}"><pre>${rightLine}</pre></td></tr>`;
}
}
}
html += '</tbody></table></div>';
return html;
}
// Minimal SequenceMatcher for line-based diff (no external deps)
class difflibSeqMatcher {
constructor(a, b) {
this.a = a;
this.b = b;
}
getOpcodes() {
// Simple LCS-based diff using dynamic programming
const m = this.a.length, n = this.b.length;
const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (this.a[i - 1] === this.b[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 build opcodes
const ops = [];
let i = m, j = n;
const equalStart = (i2, j2) => {
let ei = i2, ej = j2;
while (ei > 0 && ej > 0 && this.a[ei - 1] === this.b[ej - 1]) { ei--; ej--; }
return [ei, ej];
};
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && this.a[i - 1] === this.b[j - 1]) {
const [ei, ej] = equalStart(i, j);
if (ei < i) ops.unshift(['equal', ei, i, ej, j]);
i = ei; j = ej;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
ops.unshift(['insert', i, i, j - 1, j]);
j--;
} else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
ops.unshift(['delete', i - 1, i, j, j]);
i--;
}
}
// Merge adjacent delete+insert into replace
const merged = [];
for (let k = 0; k < ops.length; k++) {
if (merged.length > 0 && merged[merged.length - 1][0] === 'delete' && ops[k][0] === 'insert' &&
merged[merged.length - 1][4] === ops[k][3]) {
const del = merged.pop();
merged.push(['replace', del[1], del[2], ops[k][3], ops[k][4]]);
} else {
merged.push(ops[k]);
}
}
return merged;
}
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------