feat: backup restore UI (#3)
CI / Lint (ruff) (pull_request) Successful in 10s
CI / Tests (py3.10) (pull_request) Successful in 10s
CI / Tests (py3.12) (pull_request) Successful in 10s

Add list_backups / backup_label / backup_diff / restore_backup to bcc_core,
RestoreDialog to bcc.py, and a "Restore…" button in the profile top bar.

Restore is selective: only mcpServers and _disabledMcpServers are replaced;
all other keys in the config (history, project state) are preserved verbatim.
Goes through write_config() so a pre-restore backup is always created first.

Closes #3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AJ
2026-07-02 01:22:52 -04:00
parent 111fa8e367
commit df332a06ba
3 changed files with 331 additions and 0 deletions
+97
View File
@@ -14,6 +14,7 @@ in its original position.
from __future__ import annotations
import contextlib
import difflib
import functools
import glob
import json
@@ -248,6 +249,102 @@ def write_config(path: str | os.PathLike, cfg: dict) -> Path | None:
return backup
# --------------------------------------------------------------------------- #
# Backup utility functions
# --------------------------------------------------------------------------- #
_BACKUP_TS_RE = re.compile(r"(\d{8}-\d{6})")
def list_backups(config_path: Path | str) -> list[Path]:
"""Return backup files for config_path, newest-first. Returns [] if none exist."""
p = Path(config_path)
bdir = p.parent / BACKUP_DIRNAME
if not bdir.is_dir():
return []
return sorted(bdir.glob(f"{p.stem}.*.json"), reverse=True)
def backup_label(backup_path: Path | str) -> str:
"""Human-readable label derived from the backup filename timestamp."""
m = _BACKUP_TS_RE.search(Path(backup_path).name)
if not m:
return Path(backup_path).name
ts = m.group(1) # e.g. "20260702-004124"
return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:]}"
def backup_diff(
config_path: Path | str,
backup_path: Path | str,
current_cfg: dict | None = None,
) -> str:
"""
Unified diff showing what the current config would look like after restoring
the backup (servers only — non-server keys are preserved).
fromfile = current state, tofile = what will be written after restore.
Pass current_cfg to use an already-loaded (possibly repaired) in-memory config
as the baseline instead of re-reading from disk.
"""
p = Path(config_path)
bp = Path(backup_path)
if current_cfg is None:
try:
current_cfg = load_config(p)
except Exception:
current_cfg = {}
backup_servers = extract_servers(load_config(bp))
after_cfg = dict(current_cfg)
apply_servers(after_cfg, backup_servers)
before_lines = (json.dumps(current_cfg, indent=2, ensure_ascii=False) + "\n").splitlines(
keepends=True
)
after_lines = (json.dumps(after_cfg, indent=2, ensure_ascii=False) + "\n").splitlines(
keepends=True
)
result = "".join(
difflib.unified_diff(
before_lines,
after_lines,
fromfile=f"current: {p.name}",
tofile=f"restore: {bp.name}",
)
)
return result or "(no differences — backup matches current servers)"
def restore_backup(
config_path: Path | str,
backup_path: Path | str,
current_cfg: dict | None = None,
) -> Path | None:
"""
Restore server entries from backup_path into config_path.
Only mcpServers and _disabledMcpServers are replaced; all other keys in the
current config (conversation history, project state, etc.) are preserved
verbatim and in their original order.
Goes through write_config() so a pre-restore backup of the current file is
created automatically. Returns that backup path (or None if no prior file).
"""
p = Path(config_path)
if current_cfg is None:
try:
current_cfg = load_config(p)
except Exception:
current_cfg = {}
backup_servers = extract_servers(load_config(Path(backup_path)))
cfg = dict(current_cfg)
apply_servers(cfg, backup_servers)
return write_config(p, cfg)
# --------------------------------------------------------------------------- #
# Paste / import parsing
# --------------------------------------------------------------------------- #