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
|
||||
|
||||
Reference in New Issue
Block a user