feat: diff viewer backups — point 6 (backups, diff, restore)
CI / lint (push) Failing after 4s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 8s

- Backend: 3 nouveaux endpoints
  - GET /api/file/{vault}/backups — liste les backups horodatés
  - GET /api/file/{vault}/diff — diff unifié entre versions
  - POST /api/file/{vault}/restore — restaure une version (backup auto avant)
- Frontend: module backups.js — UI modale avec:
  - Sélecteur gauche (backup) / droite (version actuelle ou autre backup)
  - Diff coloré: vert pour ajouts, rouge pour suppressions
  - Bouton Restaurer avec confirmation
- Menu contextuel: clic droit fichier → 'Backups / Versions'
- CSS: styles complets pour la modale, toolbar, table de diff
- ROADMAP: point 6 marqué comme complété
This commit is contained in:
2026-06-03 22:22:50 -04:00
parent c07de4fd65
commit d1ba15c3aa
5 changed files with 967 additions and 9 deletions
+9 -9
View File
@@ -126,16 +126,16 @@
- [x] **Protection** : pas de drop sur soi-même, pas de drop dans un enfant, pas de cross-vault
- [x] **SSE + index** : mise à jour de l'index et broadcast SSE après move
### 6. Diff viewer backups
### ✅ 6. Diff viewer backups — FAIT
- **Effort** : 1 jour | **Impact** : 🟢
- Comparer versions sauvegardées dans `.obsigate-backup/`
- **Pourquoi** : ObsiGate fait un backup automatique avant chaque écriture de fichier (mécanisme déjà en place dans `secret_redactor.py` et les endpoints d'édition). Ces snapshots horodatés s'accumulent dans `.obsigate-backup/` mais ne sont pas exploitables depuis l'UI.
- **Ce que ça met en place** :
- Un endpoint `GET /api/file/{vault_name}/backups` qui liste les backups disponibles pour un fichier (avec horodatage)
- Un endpoint `GET /api/file/{vault_name}/diff` qui prend deux versions (ou une version + l'actuelle) et retourne un diff unifié (ligne par ligne)
- Une UI de comparaison dans le panneau de droite : sélecteur de version (dropdown avec les timestamps), affichage du diff côte à côte ou unifié avec coloration syntaxique (lignes ajoutées en vert, supprimées en rouge)
- Un bouton "Restaurer" pour revenir à une version précédente
- Accessible depuis le menu contextuel d'un fichier → "View backups"
- Comparer versions sauvegardées dans `.obsigate-backup/` ✅
- **Fichiers** : `backend/main.py` (3 endpoints + helpers), `frontend/js/backups.js`, `frontend/js/ui.js`, `frontend/style.css`
- [x] **`GET /api/file/{vault}/backups`** — liste tous les backups horodatés d'un fichier (triés plus récent d'abord)
- [x] **`GET /api/file/{vault}/diff`** — diff unifié entre deux versions (backup vs backup, ou backup vs actuel)
- [x] **`POST /api/file/{vault}/restore`** — restaure une version backup (backup automatique de la version actuelle avant restauration)
- [x] **UI modale** : panneau avec sélecteur gauche (backup) / droite (version actuelle ou autre backup), diff coloré (vert/rouge), bouton Restaurer avec confirmation
- [x] **Menu contextuel** : clic droit sur un fichier → "Backups / Versions"
- [x] **Coloration syntaxique diff** : lignes ajoutées en vert, supprimées en rouge, en-têtes hunk en accent
### 7. Fichiers récents par vault
- **Effort** : 0.5 jour | **Impact** : 🟡
+287
View File
@@ -333,6 +333,50 @@ class FileMoveResponse(BaseModel):
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
class BackupEntry(BaseModel):
"""A single backup version of a file."""
timestamp: int = Field(description="Unix timestamp of when the backup was created")
datetime: str = Field(description="ISO 8601 datetime string")
size: int = Field(description="File size in bytes")
filename: str = Field(description="Backup filename on disk")
class BackupListResponse(BaseModel):
"""Response listing all available backups for a file."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
backups: list[BackupEntry] = Field(description="Available backups, newest first")
class DiffRequest(BaseModel):
"""Request parameters for generating a diff."""
version: int = Field(description="Timestamp of the backup version to compare")
compare_with: Optional[int] = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
class DiffResponse(BaseModel):
"""Response containing a unified diff between two file versions."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
version: int = Field(description="Backup version timestamp (left/old side)")
compare_with: Optional[int] = Field(default=None, description="Other backup version or null for current file (right/new side)")
diff: str = Field(description="Unified diff (empty if no changes)")
class RestoreRequest(BaseModel):
"""Request to restore a file from a backup."""
version: int = Field(description="Timestamp of the backup version to restore")
class RestoreResponse(BaseModel):
"""Response after restoring a file from backup."""
success: bool = Field(description="Whether restore succeeded")
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
restored_from: int = Field(description="Timestamp of the backup used")
current_backed_up: Optional[int] = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
# ---------------------------------------------------------------------------
# SSE Manager — Server-Sent Events for real-time notifications
# ---------------------------------------------------------------------------
@@ -1875,6 +1919,249 @@ async def api_file_move(
raise HTTPException(status_code=500, detail=f"Error moving item: {str(e)}")
# ---------------------------------------------------------------------------
# Backup & Diff endpoints
# ---------------------------------------------------------------------------
def _get_backup_root() -> Path:
"""Return the backup root directory as configured."""
return Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
"""Return the directory where backups for a specific file are stored."""
backup_root = _get_backup_root()
return backup_root / vault_name / Path(relative_path).parent
def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]:
"""List all backup files for a given vault file, sorted newest first.
Backup filename format: {original_filename}.{timestamp}.bak
Returns a list of dicts with timestamp, datetime, size, filename.
"""
backup_dir = _get_backup_dir(vault_name, relative_path)
if not backup_dir.exists():
return []
original_name = Path(relative_path).name
prefix = original_name + "."
backups = []
for f in backup_dir.iterdir():
if not f.is_file():
continue
name = f.name
if not name.startswith(prefix) or not name.endswith(".bak"):
continue
# Extract timestamp: strip prefix and .bak suffix
ts_part = name[len(prefix):-len(".bak")]
try:
ts = int(ts_part)
except (ValueError, TypeError):
continue
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
backups.append({
"timestamp": ts,
"datetime": dt,
"size": f.stat().st_size,
"filename": name,
})
backups.sort(key=lambda b: b["timestamp"], reverse=True)
return backups
@app.get("/api/file/{vault_name}/backups", response_model=BackupListResponse)
async def api_file_backups(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""List all available backups for a file.
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
Returns:
BackupListResponse with backups sorted newest first.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
backups = _list_backup_files(vault_name, path)
return {"vault": vault_name, "path": path, "backups": backups}
@app.get("/api/file/{vault_name}/diff", response_model=DiffResponse)
async def api_file_diff(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
version: int = Query(..., description="Timestamp of the backup version (left/old side)"),
compare_with: Optional[int] = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
current_user=Depends(require_auth),
):
"""Generate a unified diff between a backup version and another version or the current file.
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
version: Timestamp of the backup to use as the old/left side.
compare_with: Optional timestamp of another backup as the new/right side.
If omitted, the current file on disk is used.
Returns:
DiffResponse containing the unified diff string.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
original_name = Path(path).name
def _read_backup(ts: int) -> tuple[str, str]:
"""Read a backup file. Returns (content, label)."""
backup_dir = _get_backup_dir(vault_name, path)
backup_path = backup_dir / f"{original_name}.{ts}.bak"
if not backup_path.exists():
raise HTTPException(status_code=404, detail=f"Backup version {ts} not found for {path}")
content = backup_path.read_text(encoding="utf-8")
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return content, f"{path}@{dt}"
# Read left side (backup version)
left_content, left_label = _read_backup(version)
# Read right side
if compare_with is not None:
right_content, right_label = _read_backup(compare_with)
else:
right_content = file_path.read_text(encoding="utf-8")
right_label = f"{path} (current)"
# Generate unified diff using difflib
import difflib
left_lines = left_content.splitlines(keepends=True)
right_lines = right_content.splitlines(keepends=True)
diff_lines = list(difflib.unified_diff(
left_lines, right_lines,
fromfile=left_label, tofile=right_label,
))
return {
"vault": vault_name,
"path": path,
"version": version,
"compare_with": compare_with,
"diff": "".join(diff_lines),
}
@app.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
async def api_file_restore(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
body: RestoreRequest = ..., # type: ignore
current_user=Depends(require_auth),
):
"""Restore a file from a backup version.
The current file is backed up before being overwritten (so the operation is reversible).
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
body: RestoreRequest with the backup version timestamp.
Returns:
RestoreResponse confirming the restore.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
# Check if vault is writable
if not _check_vault_writable(vault_root):
raise HTTPException(status_code=403, detail="Vault is read-only")
file_path = _resolve_safe_path(vault_root, path)
original_name = Path(path).name
backup_dir = _get_backup_dir(vault_name, path)
backup_path = backup_dir / f"{original_name}.{body.version}.bak"
if not backup_path.exists():
raise HTTPException(status_code=404, detail=f"Backup version {body.version} not found for {path}")
try:
# Backup the current file before restoring
current_backed_up = None
if file_path.exists() and file_path.is_file():
_backup_file(file_path, vault_name, path)
current_backed_up = int(time.time())
# Read backup content
backup_content = backup_path.read_text(encoding="utf-8")
# Write restored content
file_path.write_text(backup_content, encoding="utf-8")
logger.info(f"File restored from backup: {vault_name}/{path} <- version {body.version}")
# Update index
await update_single_file(vault_name, path)
# Broadcast SSE event
await sse_manager.broadcast("file_restored", {
"vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
})
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
return {
"success": True,
"vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot restore file")
except Exception as e:
logger.error(f"Error restoring file {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error restoring file: {str(e)}")
@app.get("/api/file/{vault_name}/backlinks")
async def api_file_backlinks(
vault_name: str,
+380
View File
@@ -0,0 +1,380 @@
/**
* Backup & Diff viewer module for ObsiGate.
*
* Provides a modal UI to browse file backups, compare versions with
* a unified diff view, and restore previous versions.
*
* Usage:
* import { openBackupViewer } from './backups.js';
* openBackupViewer(vaultName, filePath);
*/
import { api } from './auth.js';
import { showToast } from './ui.js';
import { refreshSidebarTreePreservingState } from './sidebar.js';
import { safeCreateIcons } from './utils.js';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const state = {
vault: null,
path: null,
backups: [],
overlay: null,
leftVersion: null, // timestamp of backup shown on left
rightVersion: null, // timestamp of backup shown on right, or null for current
diffText: '',
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Open the backup/diff viewer for a file.
* @param {string} vault - Vault name
* @param {string} path - Relative file path
*/
export async function openBackupViewer(vault, path) {
state.vault = vault;
state.path = path;
state.backups = [];
state.leftVersion = null;
state.rightVersion = null;
state.diffText = '';
// Create and show overlay
const overlay = createOverlay();
document.body.appendChild(overlay);
state.overlay = overlay;
requestAnimationFrame(() => overlay.classList.add('active'));
// Show loading state
setBody(renderLoading());
// Fetch backups
try {
const data = await api(`/api/file/${encodeURIComponent(vault)}/backups?path=${encodeURIComponent(path)}`);
state.backups = data.backups || [];
} catch (err) {
setBody(renderError('Failed to load backups'));
return;
}
if (state.backups.length === 0) {
setBody(renderEmpty());
return;
}
// Auto-select: left = most recent, right = current
state.leftVersion = state.backups[0].timestamp;
state.rightVersion = null;
// Render version selectors
renderMainView();
// Load initial diff
await loadDiff();
}
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function createOverlay() {
const overlay = document.createElement('div');
overlay.className = 'obsigate-modal-overlay backup-viewer-overlay';
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
const panel = document.createElement('div');
panel.className = 'backup-viewer-panel';
panel.id = 'backup-viewer-panel';
overlay.appendChild(panel);
return overlay;
}
function setBody(html) {
const panel = document.getElementById('backup-viewer-panel');
if (panel) panel.innerHTML = html;
}
function close() {
if (state.overlay) {
state.overlay.classList.remove('active');
setTimeout(() => {
if (state.overlay) state.overlay.remove();
state.overlay = null;
}, 200);
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
function renderLoading() {
return `
<div class="backup-header">
<h3>Backups</h3>
<button class="backup-close-btn" title="Fermer">&times;</button>
</div>
<div class="backup-body">
<div class="tree-loading" style="padding:2rem;text-align:center">
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
<p style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">Chargement des backups...</p>
</div>
</div>`;
}
function renderError(msg) {
return `
<div class="backup-header">
<h3>Backups</h3>
<button class="backup-close-btn" title="Fermer">&times;</button>
</div>
<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">
<p>${escapeHtml(msg)}</p>
</div>`;
}
function renderEmpty() {
return `
<div class="backup-header">
<h3>Backups — ${escapeHtml(state.path)}</h3>
<button class="backup-close-btn" title="Fermer">&times;</button>
</div>
<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">
<p>Aucun backup disponible pour ce fichier.</p>
<p style="font-size:0.8rem;margin-top:0.5rem">Les backups sont créés automatiquement avant chaque modification.</p>
</div>`;
}
function renderMainView() {
const b = state.backups;
const leftOpts = b.map(bk => {
const sel = bk.timestamp === state.leftVersion ? 'selected' : '';
return `<option value="${bk.timestamp}" ${sel}>${formatTimestamp(bk.timestamp)} (${formatSize(bk.size)})</option>`;
}).join('');
const rightOpts = [
`<option value="current" ${state.rightVersion === null ? 'selected' : ''}>Version actuelle</option>`,
...b.map(bk => {
const sel = bk.timestamp === state.rightVersion ? 'selected' : '';
return `<option value="${bk.timestamp}" ${sel}>${formatTimestamp(bk.timestamp)} (${formatSize(bk.size)})</option>`;
}),
].join('');
const html = `
<div class="backup-header">
<div class="backup-header-left">
<h3>Backups</h3>
<span class="backup-file-path">${escapeHtml(state.path)}</span>
</div>
<div class="backup-header-right">
<span class="backup-count">${b.length} backup${b.length > 1 ? 's' : ''}</span>
<button class="backup-close-btn" title="Fermer">&times;</button>
</div>
</div>
<div class="backup-toolbar">
<div class="backup-selector-group">
<label>Comparer :</label>
<select class="backup-select" id="backup-left-select">${leftOpts}</select>
</div>
<span class="backup-vs-label">vs</span>
<div class="backup-selector-group">
<select class="backup-select" id="backup-right-select">${rightOpts}</select>
</div>
<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>
</div>
<div class="backup-diff-container" id="backup-diff-container">
<div class="backup-diff-loading">
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
</div>
</div>
<div class="backup-footer">
<span class="backup-footer-hint">Backups automatiques avant chaque modification</span>
</div>`;
setBody(html);
// Bind events
bindEvents();
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Event bindings
// ---------------------------------------------------------------------------
function bindEvents() {
const panel = document.getElementById('backup-viewer-panel');
if (!panel) return;
// Close button
panel.querySelector('.backup-close-btn')?.addEventListener('click', close);
// Left select
const leftSelect = document.getElementById('backup-left-select');
leftSelect?.addEventListener('change', async () => {
state.leftVersion = parseInt(leftSelect.value);
await loadDiff();
});
// Right select
const rightSelect = document.getElementById('backup-right-select');
rightSelect?.addEventListener('change', async () => {
const val = rightSelect.value;
state.rightVersion = val === 'current' ? null : parseInt(val);
await loadDiff();
});
// Restore button
const restoreBtn = document.getElementById('backup-restore-btn');
restoreBtn?.addEventListener('click', async () => {
await restoreBackup();
});
}
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
async function loadDiff() {
const container = document.getElementById('backup-diff-container');
if (!container) return;
container.innerHTML = `
<div class="backup-diff-loading">
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
</div>`;
let url = `/api/file/${encodeURIComponent(state.vault)}/diff?path=${encodeURIComponent(state.path)}&version=${state.leftVersion}`;
if (state.rightVersion !== null) {
url += `&compare_with=${state.rightVersion}`;
}
try {
const data = await api(url);
state.diffText = data.diff || '';
container.innerHTML = renderDiff(state.diffText);
} catch (err) {
container.innerHTML = `<div class="backup-diff-error">Erreur: ${escapeHtml(err?.detail || err?.message || 'Unknown')}</div>`;
}
}
async function restoreBackup() {
if (!state.leftVersion) return;
const confirmed = confirm(
`Restaurer "${state.path}" à la version du ${formatTimestamp(state.leftVersion)} ?\n\n` +
`La version actuelle sera sauvegardée avant la restauration.`
);
if (!confirmed) return;
const restoreBtn = document.getElementById('backup-restore-btn');
if (restoreBtn) {
restoreBtn.disabled = true;
restoreBtn.textContent = 'Restauration...';
}
try {
const resp = await api(
`/api/file/${encodeURIComponent(state.vault)}/restore?path=${encodeURIComponent(state.path)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: state.leftVersion }),
}
);
if (resp && resp.success) {
showToast('Fichier restauré avec succès', 'success');
// Refresh sidebar and close
await refreshSidebarTreePreservingState();
close();
// Reload current file if open
if (typeof TabManager !== 'undefined' && TabManager.reloadCurrent) {
TabManager.reloadCurrent();
}
} else {
showToast('Échec de la restauration', 'error');
}
} catch (err) {
showToast(`Erreur: ${err?.detail || err?.message || 'Unknown'}`, 'error');
} finally {
if (restoreBtn) {
restoreBtn.disabled = false;
restoreBtn.innerHTML = '<i data-lucide="rotate-ccw" class="icon-sm"></i> Restaurer';
safeCreateIcons();
}
}
}
// ---------------------------------------------------------------------------
// Diff rendering
// ---------------------------------------------------------------------------
function renderDiff(diffText) {
if (!diffText) {
return '<div class="backup-diff-empty">Aucune différence — les versions sont identiques.</div>';
}
let html = '<div class="backup-diff-output"><table class="backup-diff-table">';
const lines = diffText.split('\n');
for (const line of lines) {
let cssClass = 'diff-context';
let prefix = ' ';
if (line.startsWith('@@')) {
cssClass = 'diff-hunk';
prefix = '';
} else if (line.startsWith('+')) {
cssClass = 'diff-add';
prefix = '+';
} else if (line.startsWith('-')) {
cssClass = 'diff-del';
prefix = '-';
} else if (line.startsWith('---') || line.startsWith('+++')) {
cssClass = 'diff-header-line';
prefix = '';
}
html += `<tr class="${cssClass}"><td class="diff-prefix">${prefix}</td><td class="diff-content"><pre>${escapeHtml(line)}</pre></td></tr>`;
}
html += '</table></div>';
return html;
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
function formatTimestamp(ts) {
const d = new Date(ts * 1000);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' o';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' Ko';
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
+12
View File
@@ -1675,6 +1675,8 @@ export const ContextMenuManager = {
this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
this._addSeparator();
this._addItem('bookmark-plus', 'Ajouter aux bookmarks', () => this._toggleBookmark(), false);
this._addSeparator();
this._addItem('history', 'Backups / Versions', () => this._openBackupViewer(), false);
}
this._menu.classList.add('active');
@@ -1800,6 +1802,16 @@ export const ContextMenuManager = {
DashboardBookmarkWidget.load();
}
} catch (err) { showToast("Erreur: " + err.message, "error"); }
},
_openBackupViewer() {
// Dynamically import the backup viewer module to avoid circular deps
import('./backups.js').then(mod => {
mod.openBackupViewer(this._targetVault, this._targetPath);
}).catch(err => {
showToast("Erreur lors du chargement du viewer: " + err.message, "error");
});
this.hide();
}
};
export const TabManager = {
+279
View File
@@ -6868,3 +6868,282 @@ body.popup-mode .content-area {
background: rgba(239,68,68,0.1);
}
}
/* ==========================================================================
Backup / Diff Viewer Modal
========================================================================== */
.backup-viewer-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(90vw, 960px);
height: min(85vh, 700px);
background: var(--bg-primary, #1e1e2e);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 25px 60px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 1001;
}
.backup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.backup-header-left {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.backup-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.backup-file-path {
font-size: 0.78rem;
color: var(--text-muted);
font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.backup-header-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.backup-count {
font-size: 0.75rem;
color: var(--text-muted);
background: var(--bg-tertiary);
padding: 3px 8px;
border-radius: 4px;
}
.backup-close-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
font-size: 1.4rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: color 0.15s, background 0.15s;
}
.backup-close-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.backup-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--bg-primary);
flex-shrink: 0;
}
.backup-selector-group {
display: flex;
align-items: center;
gap: 6px;
}
.backup-selector-group label {
font-size: 0.78rem;
color: var(--text-muted);
white-space: nowrap;
}
.backup-select {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.8rem;
font-family: var(--font-mono);
max-width: 240px;
cursor: pointer;
}
.backup-select:focus {
outline: none;
border-color: var(--accent);
}
.backup-vs-label {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 600;
user-select: none;
}
.backup-restore-btn {
margin-left: auto;
padding: 6px 14px;
border: 1px solid var(--accent);
border-radius: 6px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: background 0.15s, opacity 0.15s;
white-space: nowrap;
}
.backup-restore-btn:hover {
background: color-mix(in srgb, var(--accent) 20%, transparent);
}
.backup-restore-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.backup-restore-btn .icon-sm {
width: 14px;
height: 14px;
}
.backup-diff-container {
flex: 1;
overflow-y: auto;
background: var(--bg-primary);
}
.backup-diff-loading,
.backup-diff-error,
.backup-diff-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 0.85rem;
}
.backup-diff-error {
color: var(--danger, #ef4444);
}
.backup-diff-output {
padding: 0;
}
.backup-diff-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono, 'SF Mono', 'Fira Code', 'Cascadia Code', monospace);
font-size: 0.8rem;
line-height: 1.55;
table-layout: fixed;
}
.backup-diff-table td {
padding: 0;
}
.backup-diff-table td.diff-prefix {
width: 22px;
text-align: center;
user-select: none;
font-weight: 600;
padding: 1px 0;
vertical-align: top;
}
.backup-diff-table td.diff-content {
padding: 1px 8px;
vertical-align: top;
}
.backup-diff-table td.diff-content pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.backup-diff-table tr.diff-add td {
background: rgba(34, 197, 94, 0.12);
}
.backup-diff-table tr.diff-add td.diff-prefix {
color: #22c55e;
}
.backup-diff-table tr.diff-del td {
background: rgba(239, 68, 68, 0.12);
}
.backup-diff-table tr.diff-del td.diff-prefix {
color: #ef4444;
}
.backup-diff-table tr.diff-hunk td {
background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--accent);
font-weight: 600;
}
.backup-diff-table tr.diff-header-line td {
background: var(--bg-secondary);
color: var(--text-muted);
font-weight: 600;
}
.backup-diff-table tr.diff-context td {
color: var(--text-secondary);
}
.backup-body {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
}
.backup-footer {
padding: 6px 18px;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.backup-footer-hint {
font-size: 0.7rem;
color: var(--text-muted);
}