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
+6 -2
View File
@@ -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
+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
// ---------------------------------------------------------------------------
+2 -1
View File
@@ -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 }),
+41
View File
@@ -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;
}