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,
|
QHeaderView,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMenu,
|
QMenu,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
@@ -833,6 +835,97 @@ class RepairDialog(QDialog):
|
|||||||
v.addWidget(btns)
|
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
|
# Paste-JSON dialog
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -992,6 +1085,10 @@ class MainWindow(QMainWindow):
|
|||||||
reload_btn = QPushButton("Reload")
|
reload_btn = QPushButton("Reload")
|
||||||
reload_btn.clicked.connect(lambda: self.load_profile(self.current_profile, confirm=True))
|
reload_btn.clicked.connect(lambda: self.load_profile(self.current_profile, confirm=True))
|
||||||
bar.addWidget(reload_btn)
|
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()
|
bar.addStretch()
|
||||||
self.save_btn = QPushButton("Save")
|
self.save_btn = QPushButton("Save")
|
||||||
self.save_btn.setObjectName("primary")
|
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."
|
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 -------------------------------------------------- #
|
# --- dirty / status -------------------------------------------------- #
|
||||||
def _mark_dirty(self):
|
def _mark_dirty(self):
|
||||||
self.dirty = True
|
self.dirty = True
|
||||||
|
|||||||
+97
@@ -14,6 +14,7 @@ in its original position.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import difflib
|
||||||
import functools
|
import functools
|
||||||
import glob
|
import glob
|
||||||
import json
|
import json
|
||||||
@@ -248,6 +249,102 @@ def write_config(path: str | os.PathLike, cfg: dict) -> Path | None:
|
|||||||
return backup
|
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
|
# Paste / import parsing
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -318,3 +318,118 @@ def test_spawn_test_large_stderr_does_not_deadlock():
|
|||||||
assert r["returncode"] == 3
|
assert r["returncode"] == 3
|
||||||
# stderr is capped, not the full 64 KB
|
# stderr is capped, not the full 64 KB
|
||||||
assert len(r["stderr"]) <= c._STDERR_CAP
|
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