From d1ba15c3aaaa564f70e9ca547a13402a38d536ba Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 3 Jun 2026 22:22:50 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20diff=20viewer=20backups=20=E2=80=94=20p?= =?UTF-8?q?oint=206=20(backups,=20diff,=20restore)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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é --- ROADMAP.md | 18 +- backend/main.py | 287 +++++++++++++++++++++++++++++++ frontend/js/backups.js | 380 +++++++++++++++++++++++++++++++++++++++++ frontend/js/ui.js | 12 ++ frontend/style.css | 279 ++++++++++++++++++++++++++++++ 5 files changed, 967 insertions(+), 9 deletions(-) create mode 100644 frontend/js/backups.js diff --git a/ROADMAP.md b/ROADMAP.md index cdf531c..c04a83e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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** : 🟡 diff --git a/backend/main.py b/backend/main.py index 78cbae5..fa85e85 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, diff --git a/frontend/js/backups.js b/frontend/js/backups.js new file mode 100644 index 0000000..5d423ac --- /dev/null +++ b/frontend/js/backups.js @@ -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 ` +
+

Backups

+ +
+
+
+
+

Chargement des backups...

+
+
`; +} + +function renderError(msg) { + return ` +
+

Backups

+ +
+
+

${escapeHtml(msg)}

+
`; +} + +function renderEmpty() { + return ` +
+

Backups — ${escapeHtml(state.path)}

+ +
+
+

Aucun backup disponible pour ce fichier.

+

Les backups sont créés automatiquement avant chaque modification.

+
`; +} + +function renderMainView() { + const b = state.backups; + const leftOpts = b.map(bk => { + const sel = bk.timestamp === state.leftVersion ? 'selected' : ''; + return ``; + }).join(''); + + const rightOpts = [ + ``, + ...b.map(bk => { + const sel = bk.timestamp === state.rightVersion ? 'selected' : ''; + return ``; + }), + ].join(''); + + const html = ` +
+
+

Backups

+ ${escapeHtml(state.path)} +
+
+ ${b.length} backup${b.length > 1 ? 's' : ''} + +
+
+ +
+
+ + +
+ vs +
+ +
+ +
+ +
+
+
+
+
+ + `; + + 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 = ` +
+
+
`; + + 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 = `
Erreur: ${escapeHtml(err?.detail || err?.message || 'Unknown')}
`; + } +} + +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 = ' Restaurer'; + safeCreateIcons(); + } + } +} + +// --------------------------------------------------------------------------- +// Diff rendering +// --------------------------------------------------------------------------- + +function renderDiff(diffText) { + if (!diffText) { + return '
Aucune différence — les versions sont identiques.
'; + } + + let html = '
'; + 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 += ``; + } + + html += '
${prefix}
${escapeHtml(line)}
'; + 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; +} diff --git a/frontend/js/ui.js b/frontend/js/ui.js index f828b90..1330eb9 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -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 = { diff --git a/frontend/style.css b/frontend/style.css index f8a5316..b8b8adc 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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); +}