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 += `${escapeHtml(leftLines[i1 + k])} | ${escapeHtml(rightLines[j1 + k])} |
`;
+ }
+ } else if (tag === 'delete') {
+ for (let k = i1; k < i2; k++) {
+ html += `${escapeHtml(leftLines[k])} | |
`;
+ }
+ } else if (tag === 'insert') {
+ for (let k = j1; k < j2; k++) {
+ html += ` | ${escapeHtml(rightLines[k])} |
`;
+ }
+ } 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 += `${leftLine} | ${rightLine} |
`;
+ }
+ }
+ }
+
+ html += '
';
+ 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;
+}