feat: side-by-side diff view + auto-save skips backup
- 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:
+6
-2
@@ -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
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user