diff --git a/backend/main.py b/backend/main.py index f1b5878..f6b7e4f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1346,6 +1346,7 @@ async def api_file_save( vault_name: str, path: str = Query(..., description="Relative path to file"), body: dict = Body(...), + backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"), current_user=Depends(require_auth), ): """Save (overwrite) a file's content. @@ -1376,8 +1377,9 @@ async def api_file_save( content = body.get('content', '') try: - # Backup original content before overwriting - _backup_file(file_path, vault_name, path) + # Backup original content before overwriting (skip on auto-save) + if backup: + _backup_file(file_path, vault_name, path) file_path.write_text(content, encoding="utf-8") logger.info(f"File saved: {vault_name}/{path}") @@ -2119,6 +2121,8 @@ async def api_file_diff( "version": version, "compare_with": compare_with, "diff": "".join(diff_lines), + "left_content": left_content, + "right_content": right_content, } except HTTPException: raise diff --git a/frontend/js/backups.js b/frontend/js/backups.js index 3f3be15..c5e5ecf 100644 --- a/frontend/js/backups.js +++ b/frontend/js/backups.js @@ -32,6 +32,9 @@ const state = { leftVersion: null, rightVersion: null, diffText: '', + diffLeft: '', + diffRight: '', + diffMode: 'unified', // 'unified' or 'sidebyside' }; // --------------------------------------------------------------------------- @@ -208,6 +211,9 @@ function renderMainView() { +
@@ -217,7 +223,7 @@ function renderMainView() {
`; 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 = `
Erreur: ${escapeHtml(err?.detail || err?.message || 'Unknown')}
`; } @@ -348,6 +374,106 @@ function renderDiff(diffText) { return html; } +// --------------------------------------------------------------------------- +// Side-by-side diff rendering +// --------------------------------------------------------------------------- + +function renderDiffSideBySide(leftText, rightText) { + if (!leftText && !rightText) { + return '
Aucune différence — les versions sont identiques.
'; + } + + 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 = '
'; + + for (const [tag, i1, i2, j1, j2] of ops) { + if (tag === 'equal') { + for (let k = 0; k < (i2 - i1); k++) { + html += ``; + } + } else if (tag === 'delete') { + for (let k = i1; k < i2; k++) { + html += ``; + } + } else if (tag === 'insert') { + for (let k = j1; k < j2; k++) { + html += ``; + } + } 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 += ``; + } + } + } + + html += '
${escapeHtml(leftLines[i1 + k])}
${escapeHtml(rightLines[j1 + k])}
${escapeHtml(leftLines[k])}
${escapeHtml(rightLines[k])}
${leftLine}
${rightLine}
'; + 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 // --------------------------------------------------------------------------- diff --git a/frontend/js/utils.js b/frontend/js/utils.js index d82a50c..2aaa192 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -437,7 +437,8 @@ async function saveFile(silent = false) { safeCreateIcons(); } - const response = await fetch(`/api/file/${encodeURIComponent(state.editorVault)}/save?path=${encodeURIComponent(state.editorPath)}`, { + const backupParam = silent ? '&backup=false' : ''; + const response = await fetch(`/api/file/${encodeURIComponent(state.editorVault)}/save?path=${encodeURIComponent(state.editorPath)}${backupParam}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), diff --git a/frontend/style.css b/frontend/style.css index 8b75480..6639be5 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -7458,3 +7458,44 @@ body.popup-mode .content-area { font-size: 0.7rem; color: var(--text-muted); } + +/* ── Side-by-side diff view ── */ +.backup-diff-sidebyside .sidebyside { + table-layout: fixed; +} + +.backup-diff-sidebyside td.sidebyside-left { + border-right: 1px solid var(--border); +} + +.backup-diff-sidebyside td.sidebyside-right { + border-left: 1px solid var(--border); +} + +.backup-diff-sidebyside td.diff-empty-cell { + background: var(--bg-tertiary); +} + +.backup-diff-mode-btn { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + transition: all 150ms ease; +} + +.backup-diff-mode-btn:hover { + color: var(--accent); + border-color: var(--accent); +} + +.backup-diff-mode-btn .icon-sm { + width: 14px; + height: 14px; +}