feat: backup restore UI (#3)
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:
@@ -28,6 +28,8 @@ from PySide6.QtWidgets import (
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
@@ -833,6 +835,97 @@ class RepairDialog(QDialog):
|
||||
v.addWidget(btns)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Backup restore dialog: lists available backups, shows a diff of what will
|
||||
# change, and confirms before writing. Only servers are restored — all other
|
||||
# keys in the current config are preserved verbatim.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class RestoreDialog(QDialog):
|
||||
def __init__(self, parent, profile: core.Profile, full_config: dict):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"Restore from backup — {profile.label}")
|
||||
self.resize(700, 480)
|
||||
self._profile = profile
|
||||
self._full_config = full_config
|
||||
|
||||
v = QVBoxLayout(self)
|
||||
v.setSpacing(8)
|
||||
|
||||
intro = QLabel(
|
||||
"Select a backup to preview the change. Only <b>mcpServers</b> and "
|
||||
"<b>_disabledMcpServers</b> are restored — all other keys in your config are preserved."
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
v.addWidget(intro)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
# --- left: backup list ---
|
||||
left = QWidget()
|
||||
lv = QVBoxLayout(left)
|
||||
lv.setContentsMargins(0, 0, 0, 0)
|
||||
lv.addWidget(QLabel("Backups (newest first):"))
|
||||
self.backup_list = QListWidget()
|
||||
self.backup_list.currentItemChanged.connect(self._on_backup_selected)
|
||||
lv.addWidget(self.backup_list, 1)
|
||||
splitter.addWidget(left)
|
||||
|
||||
# --- right: diff preview ---
|
||||
right = QWidget()
|
||||
rv = QVBoxLayout(right)
|
||||
rv.setContentsMargins(0, 0, 0, 0)
|
||||
rv.addWidget(QLabel("Preview (what will change):"))
|
||||
self.diff_view = QPlainTextEdit()
|
||||
self.diff_view.setObjectName("diag")
|
||||
self.diff_view.setReadOnly(True)
|
||||
self.diff_view.setPlaceholderText("Select a backup on the left to see the diff.")
|
||||
rv.addWidget(self.diff_view, 1)
|
||||
splitter.addWidget(right)
|
||||
|
||||
splitter.setSizes([220, 460])
|
||||
v.addWidget(splitter, 1)
|
||||
|
||||
self.btns = QDialogButtonBox()
|
||||
self.restore_btn = self.btns.addButton("Restore", QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
self.restore_btn.setObjectName("primary")
|
||||
self.restore_btn.setEnabled(False)
|
||||
self.btns.addButton(QDialogButtonBox.StandardButton.Cancel)
|
||||
self.btns.accepted.connect(self.accept)
|
||||
self.btns.rejected.connect(self.reject)
|
||||
v.addWidget(self.btns)
|
||||
|
||||
self._populate()
|
||||
|
||||
def _populate(self):
|
||||
backups = core.list_backups(self._profile.path)
|
||||
if not backups:
|
||||
self.backup_list.addItem("(no backups found)")
|
||||
return
|
||||
for bp in backups:
|
||||
item = QListWidgetItem(core.backup_label(bp))
|
||||
item.setData(Qt.ItemDataRole.UserRole, str(bp))
|
||||
self.backup_list.addItem(item)
|
||||
|
||||
def _on_backup_selected(self, current: QListWidgetItem | None, _prev):
|
||||
if current is None or current.data(Qt.ItemDataRole.UserRole) is None:
|
||||
self.diff_view.setPlainText("")
|
||||
self.restore_btn.setEnabled(False)
|
||||
return
|
||||
bp = current.data(Qt.ItemDataRole.UserRole)
|
||||
try:
|
||||
diff = core.backup_diff(self._profile.path, bp, self._full_config)
|
||||
except Exception as e:
|
||||
diff = f"(error generating diff: {e})"
|
||||
self.diff_view.setPlainText(diff)
|
||||
self.restore_btn.setEnabled(True)
|
||||
|
||||
def selected_backup(self) -> str | None:
|
||||
item = self.backup_list.currentItem()
|
||||
if item is None:
|
||||
return None
|
||||
return item.data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Paste-JSON dialog
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -992,6 +1085,10 @@ class MainWindow(QMainWindow):
|
||||
reload_btn = QPushButton("Reload")
|
||||
reload_btn.clicked.connect(lambda: self.load_profile(self.current_profile, confirm=True))
|
||||
bar.addWidget(reload_btn)
|
||||
self.restore_btn = QPushButton("Restore…")
|
||||
self.restore_btn.setToolTip("Restore servers from a previous backup")
|
||||
self.restore_btn.clicked.connect(self._restore_from_backup)
|
||||
bar.addWidget(self.restore_btn)
|
||||
bar.addStretch()
|
||||
self.save_btn = QPushButton("Save")
|
||||
self.save_btn.setObjectName("primary")
|
||||
@@ -1544,6 +1641,28 @@ class MainWindow(QMainWindow):
|
||||
f"Saved {self.current_profile.path}{bnote} · Restart {self.current_profile.label} to apply."
|
||||
)
|
||||
|
||||
def _restore_from_backup(self):
|
||||
if not self.current_profile:
|
||||
return
|
||||
if self.dirty and not self._confirm_discard():
|
||||
return
|
||||
dlg = RestoreDialog(self, self.current_profile, self.full_config)
|
||||
if not dlg.exec():
|
||||
return
|
||||
bp = dlg.selected_backup()
|
||||
if not bp:
|
||||
return
|
||||
try:
|
||||
core.restore_backup(self.current_profile.path, bp, current_cfg=self.full_config)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Restore failed", str(e))
|
||||
return
|
||||
# Reload from disk so the UI reflects the restored state
|
||||
self.load_profile(self.current_profile, confirm=False)
|
||||
self.status.setText(
|
||||
f"Restored from {Path(bp).name} · Restart {self.current_profile.label} to apply."
|
||||
)
|
||||
|
||||
# --- dirty / status -------------------------------------------------- #
|
||||
def _mark_dirty(self):
|
||||
self.dirty = True
|
||||
|
||||
+97
@@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -318,3 +318,118 @@ def test_spawn_test_large_stderr_does_not_deadlock():
|
||||
assert r["returncode"] == 3
|
||||
# stderr is capped, not the full 64 KB
|
||||
assert len(r["stderr"]) <= c._STDERR_CAP
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 8. Backup restore
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.fixture
|
||||
def config_with_backup(tmp_path):
|
||||
"""A config file with two backups already present."""
|
||||
cfgpath = tmp_path / "claude_desktop_config.json"
|
||||
original = {
|
||||
"globalShortcut": "Cmd+Shift+Space",
|
||||
"mcpServers": {"server-v1": {"command": "node", "args": ["v1.js"]}},
|
||||
}
|
||||
cfgpath.write_text(json.dumps(original, indent=2))
|
||||
|
||||
# Create first backup (v1 state) by writing a new version
|
||||
cfg = c.load_config(cfgpath)
|
||||
servers = c.extract_servers(cfg)
|
||||
servers[0] = c.ServerEntry("server-v2", {"command": "node", "args": ["v2.js"]}, True)
|
||||
c.apply_servers(cfg, servers)
|
||||
c.write_config(cfgpath, cfg)
|
||||
|
||||
return cfgpath
|
||||
|
||||
|
||||
def test_list_backups_returns_newest_first(config_with_backup):
|
||||
backups = c.list_backups(config_with_backup)
|
||||
assert len(backups) >= 1
|
||||
# Sorted newest-first means descending lexicographic order on timestamps
|
||||
names = [b.name for b in backups]
|
||||
assert names == sorted(names, reverse=True)
|
||||
|
||||
|
||||
def test_list_backups_empty_when_no_bdir(tmp_path):
|
||||
cfgpath = tmp_path / "claude_desktop_config.json"
|
||||
cfgpath.write_text("{}")
|
||||
assert c.list_backups(cfgpath) == []
|
||||
|
||||
|
||||
def test_backup_label_parses_timestamp(config_with_backup):
|
||||
backup = c.list_backups(config_with_backup)[0]
|
||||
label = c.backup_label(backup)
|
||||
# label should look like "2026-07-02 00:41:24" (date + time, separated by two spaces)
|
||||
assert "-" in label and ":" in label
|
||||
assert len(label) == 20 # "YYYY-MM-DD HH:MM:SS" = 10 + 2 + 8
|
||||
|
||||
|
||||
def test_backup_diff_shows_server_change(config_with_backup):
|
||||
backup = c.list_backups(config_with_backup)[0]
|
||||
diff = c.backup_diff(config_with_backup, backup)
|
||||
# The diff should show the transition back to v1 servers
|
||||
assert "server-v1" in diff or "server-v2" in diff
|
||||
|
||||
|
||||
def test_backup_diff_no_diff_when_identical(tmp_path):
|
||||
cfgpath = tmp_path / "cfg.json"
|
||||
content = {"mcpServers": {"s": {"command": "node"}}}
|
||||
cfgpath.write_text(json.dumps(content, indent=2))
|
||||
bdir = cfgpath.parent / c.BACKUP_DIRNAME
|
||||
bdir.mkdir(exist_ok=True)
|
||||
import time as _time
|
||||
|
||||
stamp = _time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = bdir / f"cfg.{stamp}.json"
|
||||
backup_path.write_text(json.dumps(content, indent=2))
|
||||
diff = c.backup_diff(cfgpath, backup_path)
|
||||
assert "(no differences" in diff
|
||||
|
||||
|
||||
def test_restore_backup_replaces_servers(config_with_backup):
|
||||
# Get the v1 backup (only backup we have)
|
||||
backups = c.list_backups(config_with_backup)
|
||||
assert len(backups) >= 1
|
||||
v1_backup = backups[0]
|
||||
|
||||
# Current file has server-v2; restore should bring back server-v1
|
||||
c.restore_backup(config_with_backup, v1_backup)
|
||||
|
||||
restored = json.loads(config_with_backup.read_text())
|
||||
assert "server-v1" in restored.get("mcpServers", {})
|
||||
assert "server-v2" not in restored.get("mcpServers", {})
|
||||
|
||||
|
||||
def test_restore_preserves_non_server_keys(config_with_backup):
|
||||
backups = c.list_backups(config_with_backup)
|
||||
c.restore_backup(config_with_backup, backups[0])
|
||||
|
||||
restored = json.loads(config_with_backup.read_text())
|
||||
# Non-server key must survive
|
||||
assert restored.get("globalShortcut") == "Cmd+Shift+Space"
|
||||
|
||||
|
||||
def test_restore_creates_pre_restore_backup(config_with_backup):
|
||||
before_count = len(c.list_backups(config_with_backup))
|
||||
backups = c.list_backups(config_with_backup)
|
||||
c.restore_backup(config_with_backup, backups[0])
|
||||
after_count = len(c.list_backups(config_with_backup))
|
||||
# restore_backup goes through write_config which makes a backup first
|
||||
assert after_count == before_count + 1
|
||||
|
||||
|
||||
def test_restore_with_current_cfg_passed(config_with_backup):
|
||||
# When current_cfg is passed in (the GUI's in-memory config), it should be
|
||||
# used instead of re-reading from disk.
|
||||
backups = c.list_backups(config_with_backup)
|
||||
# Simulate the GUI passing a repaired in-memory version
|
||||
in_memory_cfg = {
|
||||
"globalShortcut": "Cmd+Shift+Space",
|
||||
"extraKey": "preserved",
|
||||
"mcpServers": {"server-v2": {"command": "node", "args": ["v2.js"]}},
|
||||
}
|
||||
c.restore_backup(config_with_backup, backups[0], current_cfg=in_memory_cfg)
|
||||
restored = json.loads(config_with_backup.read_text())
|
||||
assert restored.get("extraKey") == "preserved"
|
||||
assert "server-v1" in restored.get("mcpServers", {})
|
||||
|
||||
Reference in New Issue
Block a user