feat: detect stale config and prompt merge-or-overwrite on save
Records the file mtime at load time; before write_config() fires, re-checks it. If it changed (e.g. `claude mcp add`, a second BCC window, or Claude itself writing ~/.claude.json), StaleDialog prompts with the changed top-level key names and a masked server-section diff. "Merge & save" applies the user's in-memory server edits on top of the current on-disk file (preserving external non-server changes); "Overwrite anyway" proceeds as before. - bcc_core: config_mtime(), external_change_summary(), _server_sections() helper extracted from backup_diff for reuse - bcc.py: StaleDialog, MainWindow._loaded_mtime tracked through load/save - tests: 7 new tests (config_mtime, external_change_summary variants, AC merge test) Closes #4 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -926,6 +926,80 @@ class RestoreDialog(QDialog):
|
||||
return item.data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Stale-file conflict dialog
|
||||
# Shown when the config file was modified on disk between load and save.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class StaleDialog(QDialog):
|
||||
MERGE = 0
|
||||
OVERWRITE = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
profile_path: str,
|
||||
changed_keys: list[str],
|
||||
server_diff: str,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Config changed on disk")
|
||||
self.resize(680, 420)
|
||||
self._choice = self.MERGE
|
||||
|
||||
v = QVBoxLayout(self)
|
||||
v.setSpacing(8)
|
||||
|
||||
intro = QLabel(f"<b>{profile_path}</b> was modified on disk since you loaded it.")
|
||||
intro.setWordWrap(True)
|
||||
v.addWidget(intro)
|
||||
|
||||
if changed_keys:
|
||||
kl = QLabel("Changed keys: " + ", ".join(f"<code>{k}</code>" for k in changed_keys))
|
||||
kl.setObjectName("muted")
|
||||
kl.setWordWrap(True)
|
||||
v.addWidget(kl)
|
||||
|
||||
if server_diff:
|
||||
v.addWidget(QLabel("Server section changes (on disk vs loaded):"))
|
||||
dv = QPlainTextEdit()
|
||||
dv.setObjectName("diag")
|
||||
dv.setReadOnly(True)
|
||||
dv.setPlainText(server_diff)
|
||||
v.addWidget(dv, 1)
|
||||
else:
|
||||
note = QLabel("(Server sections unchanged — the external edit is in other keys.)")
|
||||
note.setObjectName("muted")
|
||||
v.addWidget(note)
|
||||
|
||||
desc = QLabel(
|
||||
"<b>Merge & save</b> applies your server edits on top of the current file "
|
||||
"on disk — both sets of changes are kept. "
|
||||
"<b>Overwrite</b> saves your in-memory state, discarding external changes."
|
||||
)
|
||||
desc.setObjectName("muted")
|
||||
desc.setWordWrap(True)
|
||||
v.addWidget(desc)
|
||||
|
||||
btns = QDialogButtonBox()
|
||||
merge_btn = btns.addButton("Merge && save", QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
merge_btn.setObjectName("primary")
|
||||
overwrite_btn = btns.addButton(
|
||||
"Overwrite anyway", QDialogButtonBox.ButtonRole.DestructiveRole
|
||||
)
|
||||
btns.addButton(QDialogButtonBox.StandardButton.Cancel)
|
||||
merge_btn.clicked.connect(lambda: self._choose(self.MERGE))
|
||||
overwrite_btn.clicked.connect(lambda: self._choose(self.OVERWRITE))
|
||||
btns.rejected.connect(self.reject)
|
||||
v.addWidget(btns)
|
||||
|
||||
def _choose(self, choice: int) -> None:
|
||||
self._choice = choice
|
||||
self.accept()
|
||||
|
||||
def choice(self) -> int:
|
||||
return self._choice
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Paste-JSON dialog
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1016,6 +1090,7 @@ class MainWindow(QMainWindow):
|
||||
self.full_config: dict = {}
|
||||
self.servers: list[core.ServerEntry] = []
|
||||
self.current_profile: core.Profile | None = None
|
||||
self._loaded_mtime: float | None = None
|
||||
self.dirty = False
|
||||
self._suppress_table = False
|
||||
self._suppress_sel = False
|
||||
@@ -1291,6 +1366,7 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
self.full_config = cfg
|
||||
repaired = True
|
||||
self._loaded_mtime = core.config_mtime(profile.path)
|
||||
self.current_profile = profile
|
||||
self.servers = core.extract_servers(self.full_config)
|
||||
self.dirty = False
|
||||
@@ -1627,12 +1703,52 @@ class MainWindow(QMainWindow):
|
||||
if not self._validate():
|
||||
QMessageBox.warning(self, "Can't save yet", "Fix the highlighted problem first.")
|
||||
return
|
||||
|
||||
# Stale-file check: if the file changed on disk since we loaded it, prompt.
|
||||
disk_mtime = core.config_mtime(self.current_profile.path)
|
||||
if (
|
||||
disk_mtime is not None
|
||||
and self._loaded_mtime is not None
|
||||
and disk_mtime != self._loaded_mtime
|
||||
):
|
||||
changed_keys, server_diff = core.external_change_summary(
|
||||
self.full_config, self.current_profile.path
|
||||
)
|
||||
dlg = StaleDialog(self, str(self.current_profile.path), changed_keys, server_diff)
|
||||
if not dlg.exec():
|
||||
return # user cancelled
|
||||
if dlg.choice() == StaleDialog.MERGE:
|
||||
try:
|
||||
fresh = core.load_config(self.current_profile.path)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Reload failed", str(e))
|
||||
return
|
||||
core.apply_servers(fresh, self.servers)
|
||||
try:
|
||||
backup = core.write_config(self.current_profile.path, fresh)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Save failed", str(e))
|
||||
return
|
||||
self.full_config = fresh
|
||||
self._loaded_mtime = core.config_mtime(self.current_profile.path)
|
||||
self.current_profile.config_exists = True
|
||||
self.dirty = False
|
||||
self.save_btn.setEnabled(False)
|
||||
bnote = f" · backup: {backup.name}" if backup else " · (new file)"
|
||||
self.status.setText(
|
||||
f"Merged & saved {self.current_profile.path}{bnote}"
|
||||
f" · Restart {self.current_profile.label} to apply."
|
||||
)
|
||||
return
|
||||
# else OVERWRITE: fall through to normal write
|
||||
|
||||
core.apply_servers(self.full_config, self.servers)
|
||||
try:
|
||||
backup = core.write_config(self.current_profile.path, self.full_config)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Save failed", str(e))
|
||||
return
|
||||
self._loaded_mtime = core.config_mtime(self.current_profile.path)
|
||||
self.current_profile.config_exists = True
|
||||
self.dirty = False
|
||||
self.save_btn.setEnabled(False)
|
||||
|
||||
Reference in New Issue
Block a user