3068e74e5c
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
Reported from the field: running v1.2 against a repo with v1.3.0 published gave no prompt, and there appeared to be no way to check manually. The checker itself works; it was invisible, for two reasons. #78 -- the notice was written to the shared status label, which 21 other call sites rewrite. The check runs off-thread and lands a second or two after launch, right as the user starts clicking, so the next selection or refresh wiped it. Exactly the bug fixed for the MSIX warning in #35, which got a persistent banner; that fix was never carried to the update notice. Adds NoticeBanner: a persistent, dismissible notice carrying its own action button. It's a shared widget rather than a second bespoke banner, so the next thing needing the user's attention doesn't reach for the status bar again. (The MSIX banner still uses its own QLabel -- migrating it is a follow-up, deliberately not bundled with a bug fix.) #79 -- the only 'Check for updates' affordance was a button inside the About dialog, which is not where anyone looks. Worse, the About action was created without a menu role, and Qt auto-assigns AboutRole to actions whose text begins with 'About', relocating it into the macOS application menu -- so the notice's own hint, 'Help > About to view it', pointed at a menu that on macOS doesn't contain the item. Help now has its own 'Check for updates...' item with an explicit ApplicationSpecificRole, and the About action states its AboutRole rather than inheriting it invisibly. The menu-driven check is never throttled and always reports back -- the user asked, so silence would read as broken. The decision and the wording live in core.update_notice() because the test suite has no PySide6 (CI installs pytest + cryptography only), so anything in bcc.py is untestable. A test asserts the notice text names no menu path, which is what went stale here in the first place. Closes #78 Closes #79
2784 lines
111 KiB
Python
2784 lines
111 KiB
Python
"""
|
||
mcp_manager.py -- A form-based editor for Claude Desktop MCP servers.
|
||
|
||
Edit the `mcpServers` block of one or more Claude installs without ever
|
||
hand-writing JSON. Validates, checks that each server's command actually exists
|
||
on PATH, backs up before writing, and never touches any other key in your config.
|
||
|
||
Run: python mcp_manager.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
from typing import ClassVar
|
||
|
||
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
|
||
from PySide6.QtGui import (
|
||
QAction,
|
||
QColor,
|
||
QCursor,
|
||
QDesktopServices,
|
||
QGuiApplication,
|
||
QIcon,
|
||
QKeySequence,
|
||
QPainter,
|
||
QPixmap,
|
||
)
|
||
from PySide6.QtWidgets import (
|
||
QAbstractItemView,
|
||
QApplication,
|
||
QCheckBox,
|
||
QComboBox,
|
||
QDialog,
|
||
QDialogButtonBox,
|
||
QFileDialog,
|
||
QFrame,
|
||
QGridLayout,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QInputDialog,
|
||
QLabel,
|
||
QLineEdit,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QMainWindow,
|
||
QMenu,
|
||
QMessageBox,
|
||
QPlainTextEdit,
|
||
QPushButton,
|
||
QSplitter,
|
||
QStackedWidget,
|
||
QStyledItemDelegate,
|
||
QTableWidget,
|
||
QTableWidgetItem,
|
||
QToolTip,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
import bcc_core as core
|
||
|
||
# A full ~/.claude.json with history can be huge; parsing happens on the UI
|
||
# thread during drag-and-drop import, so skip anything larger than this.
|
||
MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB
|
||
|
||
# --- One-line rebrand: change this to recolor the whole app --------------- #
|
||
ACCENT = "#f97316" # warm orange
|
||
ACCENT_DIM = "#c2570b"
|
||
BG = "#1b1d23"
|
||
PANEL = "#23262e"
|
||
PANEL_2 = "#2b2f39"
|
||
TEXT = "#e7e9ee"
|
||
MUTED = "#9aa0ad"
|
||
BORDER = "#3a3f4b"
|
||
GOOD = "#4ade80"
|
||
BAD = "#f87171"
|
||
WARN = "#fbbf24"
|
||
|
||
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN}
|
||
STATUS_GLYPH = {"ok": "●", "missing": "●", "warn": "▲", "remote": "◆", "unknown": "○"}
|
||
|
||
# Health dot (spawn-test outcome, see core.HealthStatus) shown per row in the
|
||
# server tables' "Health" column -- distinct from the PATH-dependency Status
|
||
# column above.
|
||
HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED}
|
||
HEALTH_GLYPH = {"ok": "●", "failed": "●", "untested": "○"}
|
||
|
||
STYLESHEET = f"""
|
||
/* No font-family here on purpose: Qt already uses the native system UI font
|
||
on every platform (San Francisco / Segoe UI / desktop default). Naming
|
||
web-CSS aliases like -apple-system forces a costly font-alias scan. */
|
||
* {{ font-size: 13px; color: {TEXT}; }}
|
||
QMainWindow, QDialog {{ background: {BG}; }}
|
||
QLabel#h1 {{ font-size: 15px; font-weight: 600; }}
|
||
QLabel#muted {{ color: {MUTED}; }}
|
||
QFrame#card {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px; }}
|
||
QLineEdit, QPlainTextEdit, QComboBox {{
|
||
background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px;
|
||
padding: 6px 8px; selection-background-color: {ACCENT}; selection-color: #1a1205;
|
||
}}
|
||
QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border: 1px solid {ACCENT}; }}
|
||
QComboBox::drop-down {{ border: none; width: 22px; }}
|
||
QComboBox QAbstractItemView {{ background: {PANEL_2}; border: 1px solid {BORDER};
|
||
selection-background-color: {ACCENT}; outline: none; }}
|
||
QPushButton {{ background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px;
|
||
padding: 7px 13px; }}
|
||
QPushButton:hover {{ border: 1px solid {ACCENT}; }}
|
||
QPushButton:disabled {{ color: {MUTED}; background: {PANEL}; }}
|
||
QPushButton#primary {{ background: {ACCENT}; border: 1px solid {ACCENT}; color: #1a1205; font-weight: 600; }}
|
||
QPushButton#primary:hover {{ background: {ACCENT_DIM}; }}
|
||
QPushButton#primary:disabled {{ background: {PANEL}; color: {MUTED}; border: 1px solid {BORDER}; }}
|
||
QPushButton#danger:hover {{ border: 1px solid {BAD}; color: {BAD}; }}
|
||
QTableWidget {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px;
|
||
gridline-color: transparent; outline: none; }}
|
||
QTableWidget::item {{ padding: 6px 8px; border: none; }}
|
||
QTableWidget::item:selected {{ background: {ACCENT}; color: #1a1205; }}
|
||
/* Inline cell editors: the global QLineEdit padding/radius clips the text
|
||
inside a table row, so give editors a compact, flat style instead. */
|
||
QTableWidget QLineEdit {{
|
||
background: {PANEL_2}; color: {TEXT}; border: 1px solid {ACCENT};
|
||
border-radius: 3px; padding: 0px 4px; margin: 0px;
|
||
selection-background-color: {ACCENT_DIM}; selection-color: #ffffff;
|
||
}}
|
||
QHeaderView::section {{ background: {PANEL}; color: {MUTED}; border: none;
|
||
border-bottom: 1px solid {BORDER}; padding: 8px; font-weight: 600; }}
|
||
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
|
||
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }}
|
||
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
|
||
QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }}
|
||
QLabel#warnBanner {{ color: #1a1205; background: {WARN}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }}
|
||
QFrame#noticeBanner {{ background: {PANEL_2}; border: 1px solid {ACCENT}; border-radius: 8px; }}
|
||
QLabel#noticeText {{ color: {TEXT}; }}
|
||
QPushButton#noticeClose {{ background: transparent; border: none; color: {MUTED}; font-size: 14px; padding: 2px; }}
|
||
QPushButton#noticeClose:hover {{ color: {TEXT}; }}
|
||
QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
|
||
QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
|
||
QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }}
|
||
QTableWidget#disabledTable {{ background: #202229; }}
|
||
QTableWidget#disabledTable::item:selected {{ background: {ACCENT}; color: #1a1205; }}
|
||
QPlainTextEdit#diag {{ font-family: "Menlo", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace;
|
||
font-size: 12px; background: #16181d; border: 1px solid {BORDER}; border-radius: 8px; }}
|
||
QFrame#diagCard {{ background: transparent; border: none; }}
|
||
QSplitter::handle {{ background: transparent; }}
|
||
QSplitter::handle:hover {{ background: {BORDER}; border-radius: 4px; }}
|
||
QSplitter::handle:pressed {{ background: {ACCENT}; border-radius: 4px; }}
|
||
"""
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Background reachability tester (keeps the UI responsive during the request)
|
||
# --------------------------------------------------------------------------- #
|
||
class ConnTester(QThread):
|
||
done = Signal(bool, str)
|
||
|
||
def __init__(self, url, timeout=5.0):
|
||
super().__init__()
|
||
self.url = url
|
||
self.timeout = timeout
|
||
|
||
def run(self):
|
||
ok, detail = core.test_remote(self.url, self.timeout)
|
||
self.done.emit(ok, detail)
|
||
|
||
|
||
class SpawnTester(QThread):
|
||
done = Signal(dict)
|
||
|
||
def __init__(self, data, timeout=3.0):
|
||
super().__init__()
|
||
self._data = data
|
||
self._timeout = timeout
|
||
|
||
def run(self):
|
||
result = core.spawn_test(self._data, self._timeout)
|
||
self.done.emit(result)
|
||
|
||
|
||
class UpdateCheckWorker(QThread):
|
||
"""
|
||
Fetches the latest release from the Gitea releases API off the UI thread.
|
||
|
||
Notify-only: `core.fetch_latest_release()` only ever reads release
|
||
metadata (a version tag + a URL) and never downloads or replaces the
|
||
running binary. Fails quiet — emits None on any network problem — so
|
||
it's safe to fire unattended from a silent startup check as well as from
|
||
the About dialog's "Check for updates" button.
|
||
|
||
Lifetime: the class keeps every instance alive in `_live` until its
|
||
thread has finished. Without this, a caller whose own reference dies
|
||
early (the About dialog is a temporary — closing it mid-check used to
|
||
GC the dialog and the running QThread with it) crashes the process with
|
||
"QThread: Destroyed while thread is still running". Callers may drop
|
||
their reference at any time; signal connections to a destroyed receiver
|
||
are disconnected by Qt, so a late result is simply discarded.
|
||
"""
|
||
|
||
done = Signal(object) # dict | None
|
||
|
||
_live: ClassVar[set[UpdateCheckWorker]] = set()
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
UpdateCheckWorker._live.add(self)
|
||
self.finished.connect(self._release_keepalive)
|
||
|
||
def _release_keepalive(self):
|
||
# Delivered on the main thread after run() has returned; only now is
|
||
# it safe for the last reference to drop.
|
||
UpdateCheckWorker._live.discard(self)
|
||
|
||
def run(self):
|
||
self.done.emit(core.fetch_latest_release())
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Small reusable: key/value editor (for env and headers)
|
||
# --------------------------------------------------------------------------- #
|
||
class _SecretMaskDelegate(QStyledItemDelegate):
|
||
"""Masks the DISPLAY of values whose key looks like a secret (API_KEY,
|
||
TOKEN, ...). The underlying item text stays real, so editing, dump() and
|
||
save are untouched — only what's painted on screen changes."""
|
||
|
||
def __init__(self, table):
|
||
super().__init__(table)
|
||
self._table = table
|
||
self.revealed = False
|
||
|
||
def initStyleOption(self, option, index):
|
||
super().initStyleOption(option, index)
|
||
if self.revealed or not option.text:
|
||
return
|
||
key_item = self._table.item(index.row(), 0)
|
||
if key_item and core.is_secret_key(key_item.text()):
|
||
option.text = core.MASK
|
||
|
||
|
||
class KeyValueTable(QWidget):
|
||
def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None):
|
||
super().__init__()
|
||
self._on_change = on_change
|
||
self._before_change = before_change
|
||
self._key_label = key_label
|
||
self._val_label = val_label
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(6)
|
||
self.table = QTableWidget(0, 2)
|
||
self.table.setHorizontalHeaderLabels([key_label, val_label])
|
||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
self.table.verticalHeader().setVisible(False)
|
||
# Rows tall enough that the inline editor doesn't crush the text.
|
||
self.table.verticalHeader().setDefaultSectionSize(34)
|
||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
self.table.setMinimumHeight(90)
|
||
self.table.itemChanged.connect(self._changed)
|
||
# Secret-looking values (API_KEY, TOKEN, ...) render masked by default.
|
||
self._mask_delegate = _SecretMaskDelegate(self.table)
|
||
self.table.setItemDelegateForColumn(1, self._mask_delegate)
|
||
lay.addWidget(self.table, 1)
|
||
row = QHBoxLayout()
|
||
add = QPushButton("+ Add")
|
||
rem = QPushButton("− Remove")
|
||
add.clicked.connect(self._add_row)
|
||
rem.clicked.connect(self._remove_row)
|
||
self.reveal_btn = QPushButton("Show secrets")
|
||
self.reveal_btn.setCheckable(True)
|
||
self.reveal_btn.toggled.connect(self._toggle_reveal)
|
||
row.addWidget(add)
|
||
row.addWidget(rem)
|
||
row.addStretch()
|
||
row.addWidget(self.reveal_btn)
|
||
lay.addLayout(row)
|
||
|
||
def _toggle_reveal(self, on):
|
||
self._mask_delegate.revealed = on
|
||
self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
|
||
self.table.viewport().update()
|
||
|
||
def _changed(self, item=None, *_):
|
||
if item is not None and item.column() == 0:
|
||
new_key = item.text().strip()
|
||
row = item.row()
|
||
if new_key and self._is_duplicate_key(new_key, row):
|
||
prev_key = item.data(Qt.ItemDataRole.UserRole)
|
||
prev_key = prev_key if prev_key is not None else ""
|
||
self.table.blockSignals(True)
|
||
item.setText(prev_key)
|
||
self.table.blockSignals(False)
|
||
QToolTip.showText(QCursor.pos(), f"Duplicate key '{new_key}' — reverted.")
|
||
return
|
||
item.setData(Qt.ItemDataRole.UserRole, new_key)
|
||
if self._on_change:
|
||
self._on_change()
|
||
|
||
def _is_duplicate_key(self, key: str, ignore_row: int) -> bool:
|
||
for r in range(self.table.rowCount()):
|
||
if r == ignore_row:
|
||
continue
|
||
other = self.table.item(r, 0)
|
||
if other and other.text().strip() == key:
|
||
return True
|
||
return False
|
||
|
||
def _add_row(self):
|
||
dlg = QDialog(self.window())
|
||
dlg.setWindowTitle(f"Add {self._key_label}")
|
||
dlg.setMinimumWidth(340)
|
||
v = QVBoxLayout(dlg)
|
||
v.setSpacing(10)
|
||
|
||
grid = QGridLayout()
|
||
grid.setSpacing(8)
|
||
lbl_k = QLabel(f"{self._key_label}:")
|
||
lbl_k.setObjectName("muted")
|
||
grid.addWidget(lbl_k, 0, 0)
|
||
key_edit = QLineEdit()
|
||
key_edit.setPlaceholderText(
|
||
"e.g. API_KEY" if self._key_label == "Variable" else f"{self._key_label.lower()}-name"
|
||
)
|
||
grid.addWidget(key_edit, 0, 1)
|
||
|
||
lbl_v = QLabel(f"{self._val_label}:")
|
||
lbl_v.setObjectName("muted")
|
||
grid.addWidget(lbl_v, 1, 0)
|
||
val_edit = QLineEdit()
|
||
val_edit.setPlaceholderText("value")
|
||
grid.addWidget(val_edit, 1, 1)
|
||
v.addLayout(grid)
|
||
|
||
# Shown when the typed key already exists in the table.
|
||
dup_warn = QLabel("")
|
||
dup_warn.setStyleSheet(f"color: {WARN};")
|
||
dup_warn.setWordWrap(True)
|
||
dup_warn.hide()
|
||
v.addWidget(dup_warn)
|
||
|
||
# Type an API_KEY/TOKEN-style name and the value field masks itself.
|
||
def _sync_echo(text):
|
||
secret = core.is_secret_key(text)
|
||
val_edit.setEchoMode(
|
||
QLineEdit.EchoMode.Password if secret else QLineEdit.EchoMode.Normal
|
||
)
|
||
|
||
key_edit.textChanged.connect(_sync_echo)
|
||
|
||
btns = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok)
|
||
ok_btn.setObjectName("primary")
|
||
ok_btn.setEnabled(False)
|
||
|
||
def _validate(text):
|
||
k = text.strip()
|
||
dup = bool(k) and self._is_duplicate_key(k, ignore_row=-1)
|
||
ok_btn.setEnabled(bool(k) and not dup)
|
||
dup_warn.setText(f"'{k}' already exists" if dup else "")
|
||
dup_warn.setVisible(dup)
|
||
|
||
key_edit.textChanged.connect(_validate)
|
||
btns.accepted.connect(dlg.accept)
|
||
btns.rejected.connect(dlg.reject)
|
||
v.addWidget(btns)
|
||
|
||
key_edit.setFocus()
|
||
# Enter in key field moves to value; Enter in value field submits
|
||
key_edit.returnPressed.connect(val_edit.setFocus)
|
||
val_edit.returnPressed.connect(lambda: ok_btn.click() if ok_btn.isEnabled() else None)
|
||
|
||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
k = key_edit.text().strip()
|
||
val = val_edit.text()
|
||
if not k or self._is_duplicate_key(k, ignore_row=-1):
|
||
return
|
||
if self._before_change:
|
||
self._before_change()
|
||
r = self.table.rowCount()
|
||
self.table.insertRow(r)
|
||
key_item = QTableWidgetItem(k)
|
||
key_item.setData(Qt.ItemDataRole.UserRole, k)
|
||
self.table.setItem(r, 0, key_item)
|
||
self.table.setItem(r, 1, QTableWidgetItem(val))
|
||
self._changed()
|
||
|
||
def _remove_row(self):
|
||
r = self.table.currentRow()
|
||
if r < 0:
|
||
return
|
||
if self._before_change:
|
||
self._before_change()
|
||
self.table.removeRow(r)
|
||
self._changed()
|
||
|
||
def load(self, d: dict):
|
||
self.table.blockSignals(True)
|
||
self.table.setRowCount(0)
|
||
for k, v in (d or {}).items():
|
||
r = self.table.rowCount()
|
||
self.table.insertRow(r)
|
||
key_item = QTableWidgetItem(str(k))
|
||
key_item.setData(Qt.ItemDataRole.UserRole, str(k))
|
||
self.table.setItem(r, 0, key_item)
|
||
self.table.setItem(r, 1, QTableWidgetItem(str(v)))
|
||
self.table.blockSignals(False)
|
||
|
||
def dump(self) -> dict:
|
||
out = {}
|
||
for r in range(self.table.rowCount()):
|
||
k = self.table.item(r, 0)
|
||
v = self.table.item(r, 1)
|
||
k = k.text().strip() if k else ""
|
||
v = v.text() if v else ""
|
||
if k:
|
||
out[k] = v
|
||
return out
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# The server editor panel (right side)
|
||
# --------------------------------------------------------------------------- #
|
||
class ServerEditor(QFrame):
|
||
def __init__(self, on_change, before_change=None):
|
||
super().__init__()
|
||
self.setObjectName("card")
|
||
self._on_change = on_change
|
||
self._before_change = before_change # called before any row add/remove in tables
|
||
self._extra = {} # preserve unknown server fields verbatim
|
||
self._loading = False
|
||
self._last_spawn: dict | None = None # most recent spawn_test result
|
||
|
||
outer = QVBoxLayout(self)
|
||
outer.setContentsMargins(16, 16, 16, 16)
|
||
outer.setSpacing(12)
|
||
|
||
title = QLabel("Server details")
|
||
title.setObjectName("h1")
|
||
outer.addWidget(title)
|
||
|
||
grid = QGridLayout()
|
||
grid.setVerticalSpacing(10)
|
||
grid.setHorizontalSpacing(10)
|
||
|
||
grid.addWidget(self._lbl("Name"), 0, 0)
|
||
self.name = QLineEdit()
|
||
self.name.setPlaceholderText("e.g. filesystem")
|
||
self.name.textChanged.connect(self._emit)
|
||
grid.addWidget(self.name, 0, 1)
|
||
|
||
grid.addWidget(self._lbl("Type"), 1, 0)
|
||
self.type = QComboBox()
|
||
self.type.addItems(["Local command (stdio)", "Remote (url)"])
|
||
self.type.currentIndexChanged.connect(self._type_switched)
|
||
grid.addWidget(self.type, 1, 1)
|
||
outer.addLayout(grid)
|
||
|
||
# Stacked: stdio page / remote page
|
||
self.stack = QStackedWidget()
|
||
self.stack.addWidget(self._build_stdio_page())
|
||
self.stack.addWidget(self._build_remote_page())
|
||
outer.addWidget(self.stack, 1)
|
||
|
||
# Dependency status row
|
||
dep = QHBoxLayout()
|
||
self.dep_dot = QLabel("○")
|
||
self.dep_label = QLabel("—")
|
||
self.dep_label.setObjectName("muted")
|
||
self.fix_btn = QPushButton("Use full path ↳")
|
||
self.fix_btn.setToolTip(
|
||
"Rewrite the bare command to its absolute path so any launch of Claude can find it"
|
||
)
|
||
self.fix_btn.clicked.connect(self._pin_path)
|
||
self.fix_btn.setVisible(False)
|
||
self.test_btn = QPushButton("Test connection")
|
||
self.test_btn.clicked.connect(self._test_remote)
|
||
self.test_btn.setVisible(False)
|
||
self.spawn_btn = QPushButton("Test launch")
|
||
self.spawn_btn.setToolTip("Spawn the server for 3 s and report whether it starts cleanly")
|
||
self.spawn_btn.clicked.connect(self._test_spawn)
|
||
self.spawn_btn.setVisible(False)
|
||
self.logs_btn = QPushButton("View logs")
|
||
self.logs_btn.setToolTip("Open this server's MCP log in a read-only, auto-tailing viewer")
|
||
self.logs_btn.clicked.connect(self._view_logs)
|
||
self.details_btn = QPushButton("Details ▸")
|
||
self.details_btn.setCheckable(True)
|
||
self.details_btn.toggled.connect(self._toggle_diag)
|
||
recheck = QPushButton("Re-check")
|
||
recheck.clicked.connect(self._recheck)
|
||
dep.addWidget(self.dep_dot)
|
||
dep.addWidget(self.dep_label, 1)
|
||
dep.addWidget(self.fix_btn)
|
||
dep.addWidget(self.test_btn)
|
||
dep.addWidget(self.spawn_btn)
|
||
dep.addWidget(self.logs_btn)
|
||
dep.addWidget(self.details_btn)
|
||
dep.addWidget(recheck)
|
||
outer.addLayout(dep)
|
||
|
||
# Collapsible diagnostics panel
|
||
self.diag_card = QFrame()
|
||
self.diag_card.setObjectName("diagCard")
|
||
dlay = QVBoxLayout(self.diag_card)
|
||
dlay.setContentsMargins(0, 0, 0, 0)
|
||
dlay.setSpacing(6)
|
||
self.diag_text = QPlainTextEdit()
|
||
self.diag_text.setObjectName("diag")
|
||
self.diag_text.setReadOnly(True)
|
||
self.diag_text.setMaximumHeight(190)
|
||
dlay.addWidget(self.diag_text)
|
||
crow = QHBoxLayout()
|
||
crow.addStretch()
|
||
self.copy_diag_btn = QPushButton("Copy diagnostics")
|
||
self.copy_diag_btn.clicked.connect(self._copy_diag)
|
||
crow.addWidget(self.copy_diag_btn)
|
||
dlay.addLayout(crow)
|
||
self.diag_card.setVisible(False)
|
||
outer.addWidget(self.diag_card)
|
||
|
||
self.setEnabled(False)
|
||
|
||
def _lbl(self, t):
|
||
lbl = QLabel(t)
|
||
lbl.setObjectName("muted")
|
||
return lbl
|
||
|
||
def _build_stdio_page(self):
|
||
w = QWidget()
|
||
v = QVBoxLayout(w)
|
||
v.setContentsMargins(0, 0, 0, 0)
|
||
v.setSpacing(8)
|
||
v.addWidget(self._lbl("Command"))
|
||
self.command = QLineEdit()
|
||
self.command.setPlaceholderText("npx, uvx, node, python3, or a full path")
|
||
self.command.textChanged.connect(self._emit)
|
||
v.addWidget(self.command)
|
||
args_lbl = self._lbl(
|
||
"Arguments — each line is one argument. A flag and its value go on "
|
||
"separate lines, e.g. “--directory” on line 1, “/path/to/server” on line 2."
|
||
)
|
||
args_lbl.setWordWrap(True)
|
||
v.addWidget(args_lbl)
|
||
self.args = ArgsEdit()
|
||
self.args.setPlaceholderText("--directory\n/path/to/your/server\nrun\nmain.py")
|
||
self.args.setMinimumHeight(70)
|
||
self.args.textChanged.connect(self._emit)
|
||
v.addWidget(self.args, 1)
|
||
|
||
# Shown when a line looks like several arguments typed together.
|
||
self.args_warn = QLabel("")
|
||
self.args_warn.setStyleSheet(f"color: {WARN};")
|
||
self.args_warn.setWordWrap(True)
|
||
self.args_warn.hide()
|
||
self.args_fix_btn = QPushButton("Fix: split onto separate lines")
|
||
self.args_fix_btn.clicked.connect(self._fix_args)
|
||
self.args_fix_btn.hide()
|
||
warn_row = QHBoxLayout()
|
||
warn_row.addWidget(self.args_warn, 1)
|
||
warn_row.addWidget(self.args_fix_btn)
|
||
v.addLayout(warn_row)
|
||
# Shown when an arg value looks like a raw credential.
|
||
self.secret_warn = QLabel("")
|
||
self.secret_warn.setStyleSheet(f"color: {WARN};")
|
||
self.secret_warn.setWordWrap(True)
|
||
self.secret_warn.hide()
|
||
v.addWidget(self.secret_warn)
|
||
v.addWidget(self._lbl("Environment variables"))
|
||
self.env = KeyValueTable(
|
||
"Variable", "Value", on_change=self._emit, before_change=self._before_change
|
||
)
|
||
v.addWidget(self.env, 1)
|
||
return w
|
||
|
||
def _build_remote_page(self):
|
||
w = QWidget()
|
||
v = QVBoxLayout(w)
|
||
v.setContentsMargins(0, 0, 0, 0)
|
||
v.setSpacing(8)
|
||
v.addWidget(self._lbl("URL"))
|
||
self.url = QLineEdit()
|
||
self.url.setPlaceholderText("https://mcp.example.com/sse")
|
||
self.url.textChanged.connect(self._emit)
|
||
v.addWidget(self.url)
|
||
v.addWidget(self._lbl("Transport"))
|
||
self.transport = QComboBox()
|
||
self.transport.addItems(["(auto)", "http", "sse"])
|
||
self.transport.currentIndexChanged.connect(self._emit)
|
||
v.addWidget(self.transport)
|
||
v.addWidget(self._lbl("Headers"))
|
||
self.headers = KeyValueTable(
|
||
"Header", "Value", on_change=self._emit, before_change=self._before_change
|
||
)
|
||
v.addWidget(self.headers, 1)
|
||
return w
|
||
|
||
# --- model <-> form -------------------------------------------------- #
|
||
def load_entry(self, entry: core.ServerEntry | None):
|
||
self._loading = True
|
||
if entry is None:
|
||
self.setEnabled(False)
|
||
self.name.clear()
|
||
self.command.clear()
|
||
self.args.clear()
|
||
self.url.clear()
|
||
self.env.load({})
|
||
self.headers.load({})
|
||
self.details_btn.setChecked(False)
|
||
self.diag_text.clear()
|
||
self._last_spawn = None
|
||
self._set_dep({"status": "unknown", "label": "—"})
|
||
self.args_warn.hide()
|
||
self.args_fix_btn.hide()
|
||
self.secret_warn.hide()
|
||
self._loading = False
|
||
return
|
||
self.setEnabled(True)
|
||
d = entry.data
|
||
self._extra = {k: v for k, v in d.items() if k not in core.KNOWN_FIELDS}
|
||
self.name.setText(entry.name)
|
||
# Reset BOTH pages first so a previously-selected server's values can't
|
||
# leak in if the user flips the type toggle mid-edit.
|
||
self.command.clear()
|
||
self.args.clear()
|
||
self.env.load({})
|
||
self.url.clear()
|
||
self.transport.setCurrentIndex(0)
|
||
self.headers.load({})
|
||
if entry.kind == "remote":
|
||
self.type.setCurrentIndex(1)
|
||
self.stack.setCurrentIndex(1)
|
||
self.url.setText(str(d.get("url", "")))
|
||
t = d.get("type")
|
||
self.transport.setCurrentText(t if t in ("http", "sse") else "(auto)")
|
||
self.headers.load(d.get("headers", {}))
|
||
else:
|
||
self.type.setCurrentIndex(0)
|
||
self.stack.setCurrentIndex(0)
|
||
self.command.setText(str(d.get("command", "")))
|
||
self.args.setPlainText("\n".join(str(a) for a in (d.get("args") or [])))
|
||
self.env.load(d.get("env", {}))
|
||
self._loading = False
|
||
self.refresh_dependency(auto_open=True)
|
||
self._check_args()
|
||
|
||
def dump_data(self) -> dict:
|
||
"""Build the server dict from the form, preserving unknown fields."""
|
||
data = dict(self._extra)
|
||
if self.type.currentIndex() == 1: # remote
|
||
data["url"] = self.url.text().strip()
|
||
t = self.transport.currentText()
|
||
if t in ("http", "sse"):
|
||
data["type"] = t
|
||
else:
|
||
data.pop("type", None)
|
||
headers = self.headers.dump()
|
||
if headers:
|
||
data["headers"] = headers
|
||
else:
|
||
data.pop("headers", None)
|
||
for k in ("command", "args", "env"):
|
||
data.pop(k, None)
|
||
else: # stdio
|
||
data["command"] = self.command.text().strip()
|
||
args = [ln for ln in self.args.toPlainText().splitlines() if ln.strip() != ""]
|
||
if args:
|
||
data["args"] = args
|
||
else:
|
||
data.pop("args", None)
|
||
env = self.env.dump()
|
||
if env:
|
||
data["env"] = env
|
||
else:
|
||
data.pop("env", None)
|
||
for k in ("url", "type", "headers"):
|
||
data.pop(k, None)
|
||
return data
|
||
|
||
def current_name(self) -> str:
|
||
return self.name.text().strip()
|
||
|
||
def _type_switched(self):
|
||
self.stack.setCurrentIndex(self.type.currentIndex())
|
||
self._emit()
|
||
|
||
def _emit(self):
|
||
if not self._loading and self._on_change:
|
||
self._on_change()
|
||
self.refresh_dependency(auto_open=False)
|
||
self._check_args()
|
||
|
||
# --- args sanity check ------------------------------------------------ #
|
||
def _current_arg_lines(self) -> list[str]:
|
||
return [ln for ln in self.args.toPlainText().splitlines() if ln.strip() != ""]
|
||
|
||
def _check_args(self):
|
||
"""Warn (and offer a fix) when a line looks like several args in one."""
|
||
if self.type.currentIndex() != 0: # stdio page only
|
||
self.args_warn.hide()
|
||
self.args_fix_btn.hide()
|
||
self.secret_warn.hide()
|
||
return
|
||
_, notes = core.split_suspicious_args(self._current_arg_lines())
|
||
if notes:
|
||
self.args_warn.setText("⚠ " + "\n".join(notes))
|
||
self.args_warn.show()
|
||
self.args_fix_btn.show()
|
||
else:
|
||
self.args_warn.hide()
|
||
self.args_fix_btn.hide()
|
||
warning = core.args_secret_warning({"args": self._current_arg_lines()})
|
||
if warning:
|
||
self.secret_warn.setText("⚠ " + warning)
|
||
self.secret_warn.show()
|
||
else:
|
||
self.secret_warn.hide()
|
||
|
||
def _fix_args(self):
|
||
if self._before_change:
|
||
self._before_change()
|
||
fixed, _ = core.split_suspicious_args(self._current_arg_lines())
|
||
self.args.setPlainText("\n".join(fixed)) # triggers _emit -> recheck
|
||
|
||
# --- dependency ------------------------------------------------------ #
|
||
def refresh_dependency(self, auto_open=False):
|
||
if not self.isEnabled():
|
||
self._set_dep({"status": "unknown", "label": "—"})
|
||
self.diag_text.clear()
|
||
return
|
||
data = self.dump_data()
|
||
res = core.check_dependency(data)
|
||
self._set_dep(res)
|
||
self.fix_btn.setVisible(res["status"] == "warn")
|
||
is_remote = self.type.currentIndex() == 1
|
||
self.test_btn.setVisible(is_remote)
|
||
self.spawn_btn.setVisible(not is_remote and res["status"] in ("ok", "warn"))
|
||
problem = res["status"] in ("missing", "warn", "unknown")
|
||
if auto_open and problem and not self.details_btn.isChecked():
|
||
self.details_btn.setChecked(True) # opens panel (fills text via _toggle_diag)
|
||
if self.diag_card.isVisible():
|
||
self.diag_text.setPlainText(self._full_diag_text())
|
||
|
||
def _set_dep(self, res: dict):
|
||
status = res.get("status", "unknown")
|
||
self.dep_dot.setText(STATUS_GLYPH.get(status, "○"))
|
||
self.dep_dot.setStyleSheet(f"color: {STATUS_COLORS.get(status, MUTED)}; font-size: 14px;")
|
||
self.dep_label.setText(res.get("label", "—"))
|
||
color = STATUS_COLORS.get(status, MUTED) if status in ("missing", "warn") else MUTED
|
||
self.dep_label.setStyleSheet(f"color: {color};")
|
||
|
||
def _full_diag_text(self) -> str:
|
||
text = core.diagnostics_text(self.current_name(), self.dump_data())
|
||
if self._last_spawn and self._last_spawn.get("stderr", "").strip():
|
||
text += "\n\n── stderr from last Test launch ──\n" + self._last_spawn["stderr"].strip()
|
||
return text
|
||
|
||
def _toggle_diag(self, on):
|
||
self.diag_card.setVisible(on)
|
||
self.details_btn.setText("Details ▾" if on else "Details ▸")
|
||
if on and self.isEnabled():
|
||
self.diag_text.setPlainText(self._full_diag_text())
|
||
|
||
def _recheck(self):
|
||
core.refresh_path_cache() # re-scan PATH, e.g. after installing a runtime
|
||
self.refresh_dependency(auto_open=True)
|
||
|
||
def _copy_diag(self):
|
||
QGuiApplication.clipboard().setText(self.diag_text.toPlainText())
|
||
self.copy_diag_btn.setText("Copied ✓")
|
||
QTimer.singleShot(1200, lambda: self.copy_diag_btn.setText("Copy diagnostics"))
|
||
|
||
def _pin_path(self):
|
||
new_data, note = core.pin_command_path(self.dump_data())
|
||
if not note:
|
||
return
|
||
self._loading = True
|
||
self.command.setText(str(new_data.get("command", "")))
|
||
self.args.setPlainText("\n".join(str(a) for a in (new_data.get("args") or [])))
|
||
self._loading = False
|
||
self._emit() # writes back to the model and re-checks (should now be ok)
|
||
|
||
def _test_remote(self):
|
||
url = self.url.text().strip()
|
||
if not url:
|
||
return
|
||
self.test_btn.setEnabled(False)
|
||
self.test_btn.setText("Testing...")
|
||
self.dep_dot.setText("◆")
|
||
self.dep_dot.setStyleSheet(f"color: {MUTED}; font-size: 14px;")
|
||
self.dep_label.setText("testing reachability...")
|
||
self.dep_label.setStyleSheet(f"color: {MUTED};")
|
||
self._tester = ConnTester(url, timeout=6.0)
|
||
self._tester.done.connect(self._on_test_done)
|
||
self._tester.start()
|
||
|
||
def _on_test_done(self, ok, detail):
|
||
self.test_btn.setEnabled(True)
|
||
self.test_btn.setText("Test connection")
|
||
color = GOOD if ok else BAD
|
||
self.dep_dot.setText("●")
|
||
self.dep_dot.setStyleSheet(f"color: {color}; font-size: 14px;")
|
||
self.dep_label.setText(f"reachable · {detail}" if ok else f"unreachable · {detail}")
|
||
self.dep_label.setStyleSheet(f"color: {color};")
|
||
|
||
def _test_spawn(self):
|
||
self.spawn_btn.setEnabled(False)
|
||
self.spawn_btn.setText("Launching…")
|
||
self.dep_dot.setText("◆")
|
||
self.dep_dot.setStyleSheet(f"color: {MUTED}; font-size: 14px;")
|
||
self.dep_label.setText("spawning server…")
|
||
self.dep_label.setStyleSheet(f"color: {MUTED};")
|
||
self._spawner = SpawnTester(self.dump_data(), timeout=3.0)
|
||
self._spawner.done.connect(self._on_spawn_done)
|
||
self._spawner.start()
|
||
|
||
def _on_spawn_done(self, result: dict):
|
||
self.spawn_btn.setEnabled(True)
|
||
self.spawn_btn.setText("Test launch")
|
||
self._last_spawn = result
|
||
outcome = result.get("outcome", "")
|
||
detail = result.get("detail", "")
|
||
if outcome == "ok":
|
||
color = GOOD
|
||
label = f"started · {detail}"
|
||
elif outcome == "exited":
|
||
color = WARN
|
||
label = f"exited cleanly · {detail}"
|
||
else:
|
||
color = BAD
|
||
label = f"{outcome} · {detail}"
|
||
self.dep_dot.setText("●")
|
||
self.dep_dot.setStyleSheet(f"color: {color}; font-size: 14px;")
|
||
self.dep_label.setText(label)
|
||
self.dep_label.setStyleSheet(f"color: {color};")
|
||
# Auto-open the details panel on non-ok outcomes so stderr is visible.
|
||
if outcome != "ok" and not self.details_btn.isChecked():
|
||
self.details_btn.setChecked(True) # triggers _toggle_diag → fills diag_text
|
||
elif self.diag_card.isVisible():
|
||
self.diag_text.setPlainText(self._full_diag_text())
|
||
|
||
def _view_logs(self):
|
||
name = self.current_name()
|
||
if not name:
|
||
return
|
||
dlg = LogViewerDialog(self.window(), name)
|
||
dlg.exec()
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Arguments editor: one line = one argument, with a numbered gutter so that
|
||
# model is visible (a long path on its own line reads as "arg 2", not as a
|
||
# wrapped continuation of arg 1).
|
||
# --------------------------------------------------------------------------- #
|
||
class _ArgsGutter(QWidget):
|
||
def __init__(self, editor: ArgsEdit):
|
||
super().__init__(editor)
|
||
self._editor = editor
|
||
|
||
def sizeHint(self):
|
||
return QSize(self._editor.gutter_width(), 0)
|
||
|
||
def paintEvent(self, event):
|
||
self._editor.gutter_paint(event)
|
||
|
||
|
||
class ArgsEdit(QPlainTextEdit):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||
self._gutter = _ArgsGutter(self)
|
||
self.blockCountChanged.connect(self._update_gutter_width)
|
||
self.updateRequest.connect(self._on_update_request)
|
||
self._update_gutter_width()
|
||
|
||
def gutter_width(self) -> int:
|
||
digits = max(1, len(str(self.blockCount())))
|
||
return 14 + self.fontMetrics().horizontalAdvance("9") * digits
|
||
|
||
def _update_gutter_width(self, *_):
|
||
self.setViewportMargins(self.gutter_width(), 0, 0, 0)
|
||
|
||
def _on_update_request(self, rect, dy):
|
||
if dy:
|
||
self._gutter.scroll(0, dy)
|
||
else:
|
||
self._gutter.update(0, rect.y(), self._gutter.width(), rect.height())
|
||
if rect.contains(self.viewport().rect()):
|
||
self._update_gutter_width()
|
||
|
||
def resizeEvent(self, e):
|
||
super().resizeEvent(e)
|
||
cr = self.contentsRect()
|
||
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.gutter_width(), cr.height()))
|
||
|
||
def gutter_paint(self, event):
|
||
painter = QPainter(self._gutter)
|
||
painter.setPen(QColor(MUTED))
|
||
fm = self.fontMetrics()
|
||
block = self.firstVisibleBlock()
|
||
n = block.blockNumber()
|
||
top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
|
||
bottom = top + round(self.blockBoundingRect(block).height())
|
||
while block.isValid() and top <= event.rect().bottom():
|
||
if block.isVisible() and bottom >= event.rect().top():
|
||
painter.drawText(
|
||
0,
|
||
top,
|
||
self._gutter.width() - 8,
|
||
fm.height(),
|
||
Qt.AlignmentFlag.AlignRight,
|
||
str(n + 1),
|
||
)
|
||
block = block.next()
|
||
top = bottom
|
||
bottom = top + round(self.blockBoundingRect(block).height())
|
||
n += 1
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Config-repair dialog: shown when a config file on disk fails strict JSON
|
||
# parsing but the repair pipeline can salvage it. Lists what's wrong, what
|
||
# would change, and lets the user decide. Nothing touches disk until Save.
|
||
# --------------------------------------------------------------------------- #
|
||
class RepairDialog(QDialog):
|
||
def __init__(self, parent, path, error: str, notes: list[str], preview: str):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Config file needs repair")
|
||
self.resize(620, 520)
|
||
v = QVBoxLayout(self)
|
||
v.setSpacing(10)
|
||
|
||
intro = QLabel(f"<b>{path}</b><br>isn't valid JSON: {error}")
|
||
intro.setWordWrap(True)
|
||
v.addWidget(intro)
|
||
|
||
fixes_hdr = QLabel("BCC can repair it automatically. Fixes to apply:")
|
||
v.addWidget(fixes_hdr)
|
||
fixes = QLabel("\n".join(f" • {n}" for n in notes) or " • reformat as valid JSON")
|
||
fixes.setObjectName("muted")
|
||
fixes.setWordWrap(True)
|
||
v.addWidget(fixes)
|
||
|
||
prev_hdr = QLabel("The file would become:")
|
||
v.addWidget(prev_hdr)
|
||
box = QPlainTextEdit()
|
||
box.setObjectName("diag")
|
||
box.setReadOnly(True)
|
||
box.setPlainText(preview)
|
||
v.addWidget(box, 1)
|
||
|
||
note = QLabel(
|
||
"Nothing is written yet — choosing Repair loads the fixed config into the "
|
||
"editor. The file is only rewritten when you press Save, and the broken "
|
||
"original is backed up first."
|
||
)
|
||
note.setObjectName("muted")
|
||
note.setWordWrap(True)
|
||
v.addWidget(note)
|
||
|
||
btns = QDialogButtonBox()
|
||
ok = btns.addButton("Repair && load", QDialogButtonBox.ButtonRole.AcceptRole)
|
||
ok.setObjectName("primary")
|
||
btns.addButton(QDialogButtonBox.StandardButton.Cancel)
|
||
btns.accepted.connect(self.accept)
|
||
btns.rejected.connect(self.reject)
|
||
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)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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
|
||
# --------------------------------------------------------------------------- #
|
||
class PasteDialog(QDialog):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Paste MCP JSON")
|
||
self.resize(560, 420)
|
||
self.result_servers: dict[str, dict] | None = None
|
||
v = QVBoxLayout(self)
|
||
info = QLabel(
|
||
"Paste a config snippet from any MCP doc — it doesn't have to be "
|
||
"perfect JSON. Markdown fences, comments, trailing commas, smart "
|
||
"quotes, and missing braces are fixed automatically."
|
||
)
|
||
info.setObjectName("muted")
|
||
info.setWordWrap(True)
|
||
v.addWidget(info)
|
||
self.box = QPlainTextEdit()
|
||
self.box.setPlaceholderText(
|
||
'{\n "mcpServers": {\n "brave-search": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-brave-search"],\n "env": {"BRAVE_API_KEY": "..."}\n }\n }\n}'
|
||
)
|
||
v.addWidget(self.box, 1)
|
||
self.err = QLabel("")
|
||
self.err.setWordWrap(True)
|
||
v.addWidget(self.err)
|
||
btns = QDialogButtonBox()
|
||
self.parse_btn = btns.addButton("Parse & Add", QDialogButtonBox.ButtonRole.AcceptRole)
|
||
self.parse_btn.setObjectName("primary")
|
||
self.parse_btn.setEnabled(False)
|
||
btns.addButton(QDialogButtonBox.StandardButton.Cancel)
|
||
btns.accepted.connect(self._try_parse)
|
||
btns.rejected.connect(self.reject)
|
||
v.addWidget(btns)
|
||
|
||
# Live parse-as-you-type, debounced so we don't churn on every keystroke.
|
||
self._debounce = QTimer(self)
|
||
self._debounce.setSingleShot(True)
|
||
self._debounce.setInterval(250)
|
||
self._debounce.timeout.connect(self._preview)
|
||
self.box.textChanged.connect(self._debounce.start)
|
||
|
||
def _preview(self):
|
||
text = self.box.toPlainText()
|
||
if not text.strip():
|
||
self.err.setText("")
|
||
self.parse_btn.setEnabled(False)
|
||
return
|
||
try:
|
||
servers, notes = core.parse_pasted_json_verbose(text)
|
||
except Exception as e:
|
||
self.err.setStyleSheet(f"color: {BAD};")
|
||
self.err.setText(str(e))
|
||
self.parse_btn.setEnabled(False)
|
||
return
|
||
names = ", ".join(
|
||
f"{n} ({core.ServerEntry(n, d).kind})" for n, d in list(servers.items())[:6]
|
||
)
|
||
if len(servers) > 6:
|
||
names += f", +{len(servers) - 6} more"
|
||
msg = f"✓ Will add: {names}"
|
||
if notes:
|
||
msg += "\nAuto-repaired: " + "; ".join(notes)
|
||
self.err.setStyleSheet(f"color: {GOOD};")
|
||
self.err.setText(msg)
|
||
self.parse_btn.setEnabled(True)
|
||
|
||
def _try_parse(self):
|
||
try:
|
||
self.result_servers = core.parse_pasted_json(self.box.toPlainText())
|
||
self.accept()
|
||
except Exception as e:
|
||
self.err.setStyleSheet(f"color: {BAD};")
|
||
self.err.setText(str(e))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Log viewer dialog (issue #6): a read-only, auto-tailing view of a single
|
||
# server's MCP log file. Polls on a QTimer instead of watching the filesystem
|
||
# so it works the same on every platform; never writes to the log.
|
||
# --------------------------------------------------------------------------- #
|
||
class LogViewerDialog(QDialog):
|
||
POLL_MS = 1500
|
||
MAX_TAIL_BYTES = 300_000
|
||
|
||
def __init__(self, parent, server_name: str):
|
||
super().__init__(parent)
|
||
self._name = server_name
|
||
self._last_path: Path | None = None
|
||
self._last_size: int | None = None
|
||
self.setWindowTitle(f"Logs — {server_name}")
|
||
self.resize(760, 520)
|
||
|
||
v = QVBoxLayout(self)
|
||
v.setSpacing(8)
|
||
|
||
self.path_label = QLabel("")
|
||
self.path_label.setObjectName("muted")
|
||
self.path_label.setWordWrap(True)
|
||
v.addWidget(self.path_label)
|
||
|
||
self.view = QPlainTextEdit()
|
||
self.view.setObjectName("diag")
|
||
self.view.setReadOnly(True)
|
||
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||
v.addWidget(self.view, 1)
|
||
|
||
row = QHBoxLayout()
|
||
self.status_label = QLabel("")
|
||
self.status_label.setObjectName("muted")
|
||
row.addWidget(self.status_label, 1)
|
||
refresh_btn = QPushButton("Refresh now")
|
||
refresh_btn.clicked.connect(self._poll)
|
||
row.addWidget(refresh_btn)
|
||
close_btn = QPushButton("Close")
|
||
close_btn.setObjectName("primary")
|
||
close_btn.clicked.connect(self.accept)
|
||
row.addWidget(close_btn)
|
||
v.addLayout(row)
|
||
|
||
self._timer = QTimer(self)
|
||
self._timer.setInterval(self.POLL_MS)
|
||
self._timer.timeout.connect(self._poll)
|
||
self._timer.start()
|
||
|
||
self._poll()
|
||
|
||
def _poll(self):
|
||
path = core.server_log_path(self._name)
|
||
if path is None:
|
||
self._last_path = None
|
||
self._last_size = None
|
||
self.path_label.setText(f"No log file for '{self._name}' yet.")
|
||
self.view.setPlaceholderText(
|
||
"No log yet — this fills in once the server has run at least once "
|
||
"and produced output."
|
||
)
|
||
self.view.clear()
|
||
self.status_label.setText("Waiting…")
|
||
return
|
||
|
||
self.path_label.setText(str(path))
|
||
try:
|
||
size = path.stat().st_size
|
||
except OSError:
|
||
self.status_label.setText("Log file disappeared.")
|
||
return
|
||
|
||
# Nothing changed since the last poll -> skip the re-read/re-render.
|
||
if path == self._last_path and size == self._last_size:
|
||
return
|
||
|
||
was_at_bottom = self._is_scrolled_to_bottom()
|
||
text, truncated = self._read_tail(path)
|
||
self.view.setPlainText(text)
|
||
self.status_label.setText(
|
||
f"Showing last {self.MAX_TAIL_BYTES // 1000} KB of the log." if truncated else ""
|
||
)
|
||
if was_at_bottom:
|
||
self._scroll_to_bottom()
|
||
|
||
self._last_path = path
|
||
self._last_size = size
|
||
|
||
def _read_tail(self, path: Path) -> tuple[str, bool]:
|
||
try:
|
||
size = path.stat().st_size
|
||
with open(path, "rb") as f:
|
||
truncated = size > self.MAX_TAIL_BYTES
|
||
if truncated:
|
||
f.seek(size - self.MAX_TAIL_BYTES)
|
||
data = f.read()
|
||
except OSError as e:
|
||
return f"(could not read log: {e})", False
|
||
text = data.decode("utf-8", errors="replace")
|
||
if truncated:
|
||
nl = text.find("\n")
|
||
if nl != -1:
|
||
text = text[nl + 1 :]
|
||
text = "… (earlier lines truncated) …\n" + text
|
||
return text, truncated
|
||
|
||
def _is_scrolled_to_bottom(self) -> bool:
|
||
sb = self.view.verticalScrollBar()
|
||
return sb.value() >= sb.maximum() - 4
|
||
|
||
def _scroll_to_bottom(self):
|
||
sb = self.view.verticalScrollBar()
|
||
sb.setValue(sb.maximum())
|
||
|
||
def closeEvent(self, event):
|
||
self._timer.stop()
|
||
super().closeEvent(event)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Bundled assets (icons) — resolves both a normal source run and a frozen
|
||
# PyInstaller build (onefile extracts assets under sys._MEIPASS).
|
||
# --------------------------------------------------------------------------- #
|
||
def _asset_dir() -> Path:
|
||
base = getattr(sys, "_MEIPASS", None)
|
||
return Path(base) if base else Path(__file__).resolve().parent
|
||
|
||
|
||
def _app_icon() -> QIcon:
|
||
"""Multi-resolution app/window icon from the bundled PNGs (falls back to
|
||
the .ico). Returns a null QIcon if no asset is found."""
|
||
icon = QIcon()
|
||
base = _asset_dir() / "icons" / "twin-gears" / "rounded"
|
||
for size in (16, 32, 48, 64, 128, 256, 512):
|
||
f = base / f"icon-{size}.png"
|
||
if f.is_file():
|
||
icon.addFile(str(f))
|
||
if icon.isNull():
|
||
ico = _asset_dir() / "icons" / "app.ico"
|
||
if ico.is_file():
|
||
icon.addFile(str(ico))
|
||
return icon
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# About dialog
|
||
# --------------------------------------------------------------------------- #
|
||
class AboutDialog(QDialog):
|
||
"""
|
||
App info + a manual "Check for updates" action.
|
||
|
||
Shows nothing sensitive: app name, icon, version (from
|
||
core.__version__), and links opened in the system browser via
|
||
QDesktopServices.openUrl — never navigated to in-app. The update check
|
||
itself only ever reads release metadata (see UpdateCheckWorker); it
|
||
never downloads or replaces the running binary.
|
||
"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("About Better Claude Config")
|
||
self.setFixedWidth(440)
|
||
self._worker: UpdateCheckWorker | None = None
|
||
self._release_url: str | None = None
|
||
|
||
icon_path = _asset_dir() / "icons" / "twin-gears" / "rounded" / "icon-128.png"
|
||
if icon_path.is_file():
|
||
self.setWindowIcon(QIcon(str(icon_path)))
|
||
|
||
v = QVBoxLayout(self)
|
||
v.setSpacing(10)
|
||
|
||
head = QHBoxLayout()
|
||
head.setSpacing(12)
|
||
icon_lbl = QLabel()
|
||
if icon_path.is_file():
|
||
icon_lbl.setPixmap(
|
||
QPixmap(str(icon_path)).scaled(
|
||
56,
|
||
56,
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
)
|
||
head.addWidget(icon_lbl)
|
||
|
||
title_box = QVBoxLayout()
|
||
title_box.setSpacing(2)
|
||
name_lbl = QLabel("Better Claude Config")
|
||
name_lbl.setObjectName("h1")
|
||
title_box.addWidget(name_lbl)
|
||
ver_lbl = QLabel(f"Version {core.__version__}")
|
||
ver_lbl.setObjectName("muted")
|
||
title_box.addWidget(ver_lbl)
|
||
head.addLayout(title_box, 1)
|
||
v.addLayout(head)
|
||
|
||
desc = QLabel(
|
||
"A cross-platform GUI for editing the mcpServers block of Claude "
|
||
"Desktop and Claude Code configs."
|
||
)
|
||
desc.setObjectName("muted")
|
||
desc.setWordWrap(True)
|
||
v.addWidget(desc)
|
||
|
||
if sys.platform == "darwin":
|
||
note = QLabel(
|
||
"This build isn't notarized by Apple. If macOS blocks it on first "
|
||
"launch, right-click the app ▸ Open, or allow it under System "
|
||
"Settings ▸ Privacy & Security."
|
||
)
|
||
note.setObjectName("muted")
|
||
note.setWordWrap(True)
|
||
v.addWidget(note)
|
||
|
||
links = QHBoxLayout()
|
||
for text, url in (
|
||
("Repository", core.REPO_URL),
|
||
("Issues", core.ISSUES_URL),
|
||
("MIT License", core.LICENSE_URL),
|
||
):
|
||
btn = QPushButton(text)
|
||
btn.setFlat(True)
|
||
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
btn.clicked.connect(lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u)))
|
||
links.addWidget(btn)
|
||
links.addStretch()
|
||
v.addLayout(links)
|
||
|
||
# --- update check ----------------------------------------------- #
|
||
upd_row = QHBoxLayout()
|
||
self.check_btn = QPushButton("Check for updates")
|
||
self.check_btn.clicked.connect(self._check_for_updates)
|
||
upd_row.addWidget(self.check_btn)
|
||
self.update_status = QLabel("")
|
||
self.update_status.setObjectName("muted")
|
||
self.update_status.setWordWrap(True)
|
||
upd_row.addWidget(self.update_status, 1)
|
||
v.addLayout(upd_row)
|
||
|
||
self.release_btn = QPushButton("Open releases page")
|
||
self.release_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.release_btn.clicked.connect(self._open_release_page)
|
||
self.release_btn.hide()
|
||
v.addWidget(self.release_btn)
|
||
|
||
self.auto_check_box = QCheckBox("Automatically check for updates on startup")
|
||
st = QSettings("BCC", "BetterClaudeConfig")
|
||
self.auto_check_box.setChecked(bool(st.value("update/autoCheck", True, type=bool)))
|
||
self.auto_check_box.toggled.connect(self._toggle_auto_check)
|
||
v.addWidget(self.auto_check_box)
|
||
|
||
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||
btns.rejected.connect(self.reject)
|
||
btns.button(QDialogButtonBox.StandardButton.Close).clicked.connect(self.accept)
|
||
v.addWidget(btns)
|
||
|
||
def _toggle_auto_check(self, on: bool):
|
||
QSettings("BCC", "BetterClaudeConfig").setValue("update/autoCheck", on)
|
||
|
||
def _check_for_updates(self):
|
||
self.check_btn.setEnabled(False)
|
||
self.release_btn.hide()
|
||
self.update_status.setStyleSheet(f"color: {MUTED};")
|
||
self.update_status.setText("Checking…")
|
||
self._worker = UpdateCheckWorker()
|
||
self._worker.done.connect(self._on_check_done)
|
||
self._worker.start()
|
||
|
||
def _on_check_done(self, release: dict | None):
|
||
self.check_btn.setEnabled(True)
|
||
self._worker = None
|
||
if not release:
|
||
self.update_status.setStyleSheet(f"color: {MUTED};")
|
||
self.update_status.setText("Couldn't check for updates (offline?).")
|
||
return
|
||
if core.is_newer_version(core.__version__, release["version"]):
|
||
self.update_status.setStyleSheet(f"color: {ACCENT};")
|
||
self.update_status.setText(f"{release['version']} available.")
|
||
self._release_url = release.get("url") or core.RELEASES_URL
|
||
self.release_btn.show()
|
||
else:
|
||
self.update_status.setStyleSheet(f"color: {GOOD};")
|
||
self.update_status.setText("You're up to date.")
|
||
|
||
def _open_release_page(self):
|
||
QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL))
|
||
|
||
|
||
class NoticeBanner(QFrame):
|
||
"""A persistent, dismissible notice with an optional action button.
|
||
|
||
The status bar is the wrong home for anything the user needs to act on --
|
||
21 call sites rewrite it, so a message posted there is gone by the next
|
||
click. That wiped the MSIX warning (#35) and then the update notice (#78).
|
||
This is the shared mechanism so it doesn't happen a third time.
|
||
"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setObjectName("noticeBanner")
|
||
row = QHBoxLayout(self)
|
||
row.setContentsMargins(10, 8, 8, 8)
|
||
row.setSpacing(8)
|
||
self._label = QLabel("")
|
||
self._label.setObjectName("noticeText")
|
||
self._label.setWordWrap(True)
|
||
row.addWidget(self._label, 1)
|
||
self._action_btn = QPushButton("")
|
||
self._action_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self._action_btn.hide()
|
||
row.addWidget(self._action_btn)
|
||
self._close_btn = QPushButton("\u2715")
|
||
self._close_btn.setObjectName("noticeClose")
|
||
self._close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self._close_btn.setFixedWidth(26)
|
||
self._close_btn.setToolTip("Dismiss")
|
||
self._close_btn.clicked.connect(self.hide)
|
||
row.addWidget(self._close_btn)
|
||
self.hide()
|
||
|
||
def show_notice(self, text: str, action_label: str = "", on_action=None):
|
||
self._label.setText(text)
|
||
self._label.setToolTip(text)
|
||
# Reconnect cleanly: a banner reused for a second notice would
|
||
# otherwise fire the previous notice's action too.
|
||
with contextlib.suppress(RuntimeError, TypeError):
|
||
self._action_btn.clicked.disconnect()
|
||
if action_label and on_action is not None:
|
||
self._action_btn.setText(action_label)
|
||
self._action_btn.clicked.connect(lambda _=False: on_action())
|
||
self._action_btn.show()
|
||
else:
|
||
self._action_btn.hide()
|
||
self.show()
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS
|
||
# waiting for the old instance to exit, so it must run off the UI thread.
|
||
# --------------------------------------------------------------------------- #
|
||
class RestartWorker(QThread):
|
||
done = Signal(object) # core.RestartResult
|
||
|
||
def run(self):
|
||
self.done.emit(core.restart_claude_desktop())
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Main window
|
||
# --------------------------------------------------------------------------- #
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("Better Claude Config")
|
||
self.resize(940, 640)
|
||
self.setAcceptDrops(True)
|
||
self._build_menu_bar()
|
||
|
||
self.profiles: list[core.Profile] = []
|
||
self.full_config: dict = {}
|
||
self.servers: list[core.ServerEntry] = []
|
||
self.current_profile: core.Profile | None = None
|
||
self._loaded_stat: core.ConfigStat | None = None
|
||
self.dirty = False
|
||
self._suppress_table = False
|
||
self._suppress_sel = False
|
||
self._focused_table = None
|
||
self._row_of_index: dict[int, tuple] = {}
|
||
self._undo_stack: list[list] = [] # each entry: snapshot of self.servers
|
||
self._filter_query = ""
|
||
self._health: dict[str, tuple[str, str]] = {} # server name -> (HealthStatus, summary)
|
||
self._test_all_queue: list[core.ServerEntry] = []
|
||
self._test_all_total = 0
|
||
self._test_all_done = 0
|
||
self._health_tester: SpawnTester | None = None
|
||
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
root = QVBoxLayout(central)
|
||
root.setContentsMargins(14, 14, 14, 10)
|
||
root.setSpacing(12)
|
||
|
||
root.addLayout(self._build_topbar())
|
||
|
||
# Persistent warning banner (MSIX-virtualized config, etc.). Lives in
|
||
# its own widget because the status bar is rewritten on every action,
|
||
# which used to wipe the warning before the user could read it.
|
||
self.warn_banner = QLabel("")
|
||
self.warn_banner.setObjectName("warnBanner")
|
||
self.warn_banner.setWordWrap(True)
|
||
self.warn_banner.hide()
|
||
root.addWidget(self.warn_banner)
|
||
|
||
# Update availability gets its own persistent banner rather than a
|
||
# status-line write, which the next UI action overwrites (#78).
|
||
self.update_banner = NoticeBanner(self)
|
||
root.addWidget(self.update_banner)
|
||
|
||
# User-draggable divider between the server list and the editor.
|
||
split = QSplitter(Qt.Orientation.Horizontal)
|
||
split.setChildrenCollapsible(False)
|
||
split.setHandleWidth(10)
|
||
split.addWidget(self._build_left())
|
||
self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo)
|
||
split.addWidget(self.editor)
|
||
split.setStretchFactor(0, 3)
|
||
split.setStretchFactor(1, 4)
|
||
split.setSizes([390, 520])
|
||
self.hsplit = split
|
||
root.addWidget(split, 1)
|
||
|
||
root.addLayout(self._build_actionbar())
|
||
|
||
status_row = QHBoxLayout()
|
||
status_row.setContentsMargins(0, 0, 0, 0)
|
||
self.status = QLabel("Ready.")
|
||
self.status.setObjectName("statusbar")
|
||
status_row.addWidget(self.status, 1)
|
||
self.restart_btn = QPushButton("Restart Claude Desktop")
|
||
self.restart_btn.setToolTip(
|
||
"Quit and relaunch Claude Desktop so the saved config takes effect"
|
||
)
|
||
self.restart_btn.clicked.connect(self._restart_claude_desktop)
|
||
self.restart_btn.hide()
|
||
status_row.addWidget(self.restart_btn)
|
||
root.addLayout(status_row)
|
||
|
||
self._restore_layout()
|
||
self.reload_profiles()
|
||
self._maybe_auto_check_updates()
|
||
|
||
# --- menu bar ---------------------------------------------------------- #
|
||
def _build_menu_bar(self):
|
||
help_menu = self.menuBar().addMenu("&Help")
|
||
|
||
# "Check for updates" used to exist only as a button inside the About
|
||
# dialog, which is not somewhere anyone looks for it (#79).
|
||
update_action = QAction("Check for updates…", self)
|
||
# Explicit role: macOS relocates actions it recognises by text, and
|
||
# some Qt versions treat "update" as application-menu material. Pin it
|
||
# so the item stays where the menu says it is on every platform.
|
||
update_action.setMenuRole(QAction.MenuRole.ApplicationSpecificRole)
|
||
update_action.triggered.connect(self.check_for_updates)
|
||
help_menu.addAction(update_action)
|
||
help_menu.addSeparator()
|
||
|
||
about_action = QAction("About Better Claude Config…", self)
|
||
# Qt auto-assigns AboutRole to actions whose text starts with "About",
|
||
# which moves this into the application menu on macOS. That is the
|
||
# right home there -- state it explicitly rather than inheriting it by
|
||
# accident, since the behaviour is invisible from this call site.
|
||
about_action.setMenuRole(QAction.MenuRole.AboutRole)
|
||
about_action.triggered.connect(self._show_about)
|
||
help_menu.addAction(about_action)
|
||
|
||
def _show_update_notice(self, notice: dict):
|
||
"""Surface an available update where it survives the next click."""
|
||
url = notice["url"]
|
||
self.update_banner.show_notice(
|
||
notice["text"],
|
||
action_label="Open releases page",
|
||
on_action=lambda: QDesktopServices.openUrl(QUrl(url)),
|
||
)
|
||
|
||
def check_for_updates(self):
|
||
"""Menu-driven check. Unlike the startup check this is never throttled
|
||
and always reports back -- the user asked, so silence would read as a
|
||
broken button."""
|
||
self.status.setText("Checking for updates…")
|
||
self._menu_update_worker = UpdateCheckWorker()
|
||
self._menu_update_worker.done.connect(self._on_menu_update_checked)
|
||
self._menu_update_worker.start()
|
||
|
||
def _on_menu_update_checked(self, release: dict | None):
|
||
self._menu_update_worker = None
|
||
if release is None:
|
||
self.status.setText("Couldn't check for updates (offline?).")
|
||
return
|
||
QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time())
|
||
notice = core.update_notice(core.__version__, release)
|
||
if notice:
|
||
self._show_update_notice(notice)
|
||
self.status.setText(f"Update available: {notice['version']}")
|
||
else:
|
||
self.update_banner.hide()
|
||
self.status.setText(f"You're up to date ({core.__version__}).")
|
||
|
||
def _show_about(self):
|
||
AboutDialog(self).exec()
|
||
|
||
# --- update check (silent, throttled, off-thread) --------------------- #
|
||
def _maybe_auto_check_updates(self):
|
||
st = QSettings("BCC", "BetterClaudeConfig")
|
||
if not bool(st.value("update/autoCheck", True, type=bool)):
|
||
return
|
||
last = float(st.value("update/lastCheck", 0.0, type=float) or 0.0)
|
||
if (time.time() - last) < 86400: # at most once/day
|
||
return
|
||
self._startup_update_worker = UpdateCheckWorker()
|
||
self._startup_update_worker.done.connect(self._on_startup_update_checked)
|
||
self._startup_update_worker.start()
|
||
|
||
def _on_startup_update_checked(self, release: dict | None):
|
||
self._startup_update_worker = None
|
||
if release is None:
|
||
return # offline/failed check: don't advance lastCheck, allow retry
|
||
QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time())
|
||
notice = core.update_notice(core.__version__, release)
|
||
if notice:
|
||
self._show_update_notice(notice)
|
||
|
||
# --- layout persistence ---------------------------------------------- #
|
||
def _restore_layout(self):
|
||
st = QSettings("BCC", "BetterClaudeConfig")
|
||
if geo := st.value("window/geometry"):
|
||
self.restoreGeometry(geo)
|
||
if hs := st.value("window/hsplit"):
|
||
self.hsplit.restoreState(hs)
|
||
if vs := st.value("window/vsplit"):
|
||
self.vsplit.restoreState(vs)
|
||
|
||
def _save_layout(self):
|
||
st = QSettings("BCC", "BetterClaudeConfig")
|
||
st.setValue("window/geometry", self.saveGeometry())
|
||
st.setValue("window/hsplit", self.hsplit.saveState())
|
||
st.setValue("window/vsplit", self.vsplit.saveState())
|
||
|
||
# --- top bar --------------------------------------------------------- #
|
||
def _build_topbar(self):
|
||
bar = QHBoxLayout()
|
||
lbl = QLabel("Install")
|
||
lbl.setObjectName("muted")
|
||
bar.addWidget(lbl)
|
||
self.profile_combo = QComboBox()
|
||
self.profile_combo.setMinimumWidth(220)
|
||
self.profile_combo.currentIndexChanged.connect(self._profile_selected)
|
||
bar.addWidget(self.profile_combo)
|
||
add_cfg = QPushButton("Add config...")
|
||
add_cfg.clicked.connect(self.add_custom_config)
|
||
bar.addWidget(add_cfg)
|
||
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")
|
||
self.save_btn.clicked.connect(self.save)
|
||
bar.addWidget(self.save_btn)
|
||
return bar
|
||
|
||
# --- left (server table) -------------------------------------------- #
|
||
def _make_server_table(self, object_name=None):
|
||
t = QTableWidget(0, 5)
|
||
if object_name:
|
||
t.setObjectName(object_name)
|
||
t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status", "Health"])
|
||
t.verticalHeader().setVisible(False)
|
||
t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||
t.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
h = t.horizontalHeader()
|
||
h.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||
h.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
h.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||
h.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||
h.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||
t.itemSelectionChanged.connect(lambda tbl=t: self._on_selection(tbl))
|
||
t.itemChanged.connect(self._table_item_changed)
|
||
return t
|
||
|
||
def _build_left(self):
|
||
card = QFrame()
|
||
card.setObjectName("card")
|
||
v = QVBoxLayout(card)
|
||
v.setContentsMargins(12, 12, 12, 12)
|
||
v.setSpacing(8)
|
||
head = QLabel("MCP servers")
|
||
head.setObjectName("h1")
|
||
v.addWidget(head)
|
||
|
||
search_row = QHBoxLayout()
|
||
self.search_box = QLineEdit()
|
||
self.search_box.setPlaceholderText("Search servers by name, command, or url…")
|
||
self.search_box.setClearButtonEnabled(True)
|
||
self.search_box.textChanged.connect(self._on_search_changed)
|
||
search_row.addWidget(self.search_box, 1)
|
||
self.enable_all_btn = QPushButton("All on")
|
||
self.enable_all_btn.setToolTip("Enable every server")
|
||
self.enable_all_btn.clicked.connect(lambda: self._set_all_enabled(True))
|
||
search_row.addWidget(self.enable_all_btn)
|
||
self.disable_all_btn = QPushButton("All off")
|
||
self.disable_all_btn.setToolTip("Disable every server")
|
||
self.disable_all_btn.clicked.connect(lambda: self._set_all_enabled(False))
|
||
search_row.addWidget(self.disable_all_btn)
|
||
v.addLayout(search_row)
|
||
|
||
# Named server sets (issue #52): apply a saved Active/Disabled split
|
||
# in one click. Sets live in the config file under _bccServerSets.
|
||
sets_row = QHBoxLayout()
|
||
sets_lbl = QLabel("Set")
|
||
sets_lbl.setObjectName("muted")
|
||
sets_row.addWidget(sets_lbl)
|
||
self.sets_combo = QComboBox()
|
||
self.sets_combo.setMinimumWidth(120)
|
||
sets_row.addWidget(self.sets_combo, 1)
|
||
self.apply_set_btn = QPushButton("Apply")
|
||
self.apply_set_btn.setToolTip("Enable exactly this set's servers; disable the rest")
|
||
self.apply_set_btn.clicked.connect(self._apply_selected_set)
|
||
sets_row.addWidget(self.apply_set_btn)
|
||
self.save_set_btn = QPushButton("Save set…")
|
||
self.save_set_btn.setToolTip("Save the current Active/Disabled split as a named set")
|
||
self.save_set_btn.clicked.connect(self._save_set)
|
||
sets_row.addWidget(self.save_set_btn)
|
||
self.del_set_btn = QPushButton("−")
|
||
self.del_set_btn.setToolTip("Delete the selected set")
|
||
self.del_set_btn.setMaximumWidth(32)
|
||
self.del_set_btn.clicked.connect(self._delete_set)
|
||
sets_row.addWidget(self.del_set_btn)
|
||
v.addLayout(sets_row)
|
||
|
||
# Active and Disabled sections live in a vertical splitter so the user
|
||
# can drag the divider instead of being stuck with a fixed-height
|
||
# disabled list.
|
||
active_box = QWidget()
|
||
av = QVBoxLayout(active_box)
|
||
av.setContentsMargins(0, 0, 0, 0)
|
||
av.setSpacing(8)
|
||
self.active_label = QLabel("Active")
|
||
self.active_label.setObjectName("section")
|
||
av.addWidget(self.active_label)
|
||
self.active_table = self._make_server_table()
|
||
av.addWidget(self.active_table, 1)
|
||
self.active_empty = QLabel("No active servers. Add one, or Paste JSON.")
|
||
self.active_empty.setObjectName("placeholder")
|
||
self.active_empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.active_empty.hide()
|
||
av.addWidget(self.active_empty)
|
||
|
||
disabled_box = QWidget()
|
||
dv = QVBoxLayout(disabled_box)
|
||
dv.setContentsMargins(0, 0, 0, 0)
|
||
dv.setSpacing(8)
|
||
self.disabled_label = QLabel("⊘ Disabled")
|
||
self.disabled_label.setObjectName("sectionDisabled")
|
||
dv.addWidget(self.disabled_label)
|
||
self.disabled_table = self._make_server_table(object_name="disabledTable")
|
||
dv.addWidget(self.disabled_table, 1)
|
||
self.disabled_empty = QLabel("Nothing disabled.")
|
||
self.disabled_empty.setObjectName("placeholder")
|
||
self.disabled_empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
dv.addWidget(self.disabled_empty)
|
||
|
||
vsplit = QSplitter(Qt.Orientation.Vertical)
|
||
vsplit.setChildrenCollapsible(False)
|
||
vsplit.setHandleWidth(10)
|
||
vsplit.addWidget(active_box)
|
||
vsplit.addWidget(disabled_box)
|
||
vsplit.setStretchFactor(0, 3)
|
||
vsplit.setStretchFactor(1, 1)
|
||
vsplit.setSizes([360, 150])
|
||
self.vsplit = vsplit
|
||
v.addWidget(vsplit, 1)
|
||
return card
|
||
|
||
# --- bottom action bar ---------------------------------------------- #
|
||
def _build_actionbar(self):
|
||
bar = QHBoxLayout()
|
||
self.add_btn = QPushButton("+ Add")
|
||
self.dup_btn = QPushButton("Duplicate")
|
||
self.del_btn = QPushButton("Delete")
|
||
self.del_btn.setObjectName("danger")
|
||
self.paste_btn = QPushButton("Paste JSON...")
|
||
self.copy_btn = QPushButton("Copy to ▸")
|
||
self.undo_btn = QPushButton("Undo")
|
||
self.undo_btn.setEnabled(False)
|
||
self.undo_btn.setToolTip("Undo last change (Ctrl+Z)")
|
||
self.test_all_btn = QPushButton("Test all")
|
||
self.test_all_btn.setToolTip(
|
||
"Spawn-test every enabled local server, one at a time, and fill in the Health column"
|
||
)
|
||
self.add_btn.clicked.connect(self.add_server)
|
||
self.dup_btn.clicked.connect(self.duplicate_server)
|
||
self.del_btn.clicked.connect(self.delete_server)
|
||
self.paste_btn.clicked.connect(self.paste_json)
|
||
self.copy_btn.clicked.connect(self.copy_to_menu)
|
||
self.undo_btn.clicked.connect(self._undo)
|
||
self.test_all_btn.clicked.connect(self._test_all_servers)
|
||
for b in (
|
||
self.add_btn,
|
||
self.dup_btn,
|
||
self.del_btn,
|
||
self.paste_btn,
|
||
self.copy_btn,
|
||
self.undo_btn,
|
||
self.test_all_btn,
|
||
):
|
||
bar.addWidget(b)
|
||
# Ctrl+Z shortcut
|
||
undo_action = QAction(self)
|
||
undo_action.setShortcut("Ctrl+Z")
|
||
undo_action.triggered.connect(self._undo)
|
||
self.addAction(undo_action)
|
||
# Ctrl+S / Cmd+S shortcut — routed through a guard so it respects
|
||
# the same dirty/validation gating as the Save button.
|
||
save_action = QAction(self)
|
||
save_action.setShortcut(QKeySequence.StandardKey.Save)
|
||
save_action.triggered.connect(self._save_shortcut)
|
||
self.addAction(save_action)
|
||
bar.addStretch()
|
||
self.validation_lbl = QLabel("")
|
||
bar.addWidget(self.validation_lbl)
|
||
return bar
|
||
|
||
# --- undo ------------------------------------------------------------ #
|
||
def _push_undo(self):
|
||
"""Snapshot the current server list onto the undo stack."""
|
||
self._undo_stack.append(
|
||
[core.ServerEntry(s.name, dict(s.data), s.enabled) for s in self.servers]
|
||
)
|
||
if len(self._undo_stack) > 50:
|
||
self._undo_stack.pop(0)
|
||
self.undo_btn.setEnabled(True)
|
||
|
||
def _undo(self):
|
||
if not self._undo_stack:
|
||
return
|
||
self.servers = self._undo_stack.pop()
|
||
self.undo_btn.setEnabled(bool(self._undo_stack))
|
||
cur = self._current_index()
|
||
sel = cur if 0 <= cur < len(self.servers) else (0 if self.servers else -1)
|
||
self._refresh_tables(select_index=sel)
|
||
self._mark_dirty()
|
||
self.status.setText("Undone.")
|
||
|
||
def _set_all_enabled(self, enabled: bool):
|
||
"""Flip every server's enabled flag in one step (one undo snapshot)."""
|
||
if not self.servers or all(s.enabled == enabled for s in self.servers):
|
||
return # nothing to change
|
||
cur = self._current_index()
|
||
self._push_undo()
|
||
for s in self.servers:
|
||
s.enabled = enabled
|
||
sel = cur if 0 <= cur < len(self.servers) else None
|
||
self._refresh_tables(select_index=sel)
|
||
self._mark_dirty()
|
||
self.status.setText("All servers enabled." if enabled else "All servers disabled.")
|
||
|
||
# --- profiles -------------------------------------------------------- #
|
||
def reload_profiles(self):
|
||
discovered = core.discover_profiles()
|
||
# keep any manually-added custom profiles around
|
||
customs = [p for p in self.profiles if str(p.path) not in {str(d.path) for d in discovered}]
|
||
self.profiles = discovered + customs
|
||
self.profile_combo.blockSignals(True)
|
||
self.profile_combo.clear()
|
||
for p in self.profiles:
|
||
tag = "" if p.config_exists else " (no config yet)"
|
||
self.profile_combo.addItem(f"{p.label}{tag}")
|
||
self.profile_combo.blockSignals(False)
|
||
if self.profiles:
|
||
self.profile_combo.setCurrentIndex(0)
|
||
self.load_profile(self.profiles[0])
|
||
else:
|
||
self.status.setText('No Claude installs found. Use "Add config…" to point at one.')
|
||
self._maybe_warn_msix()
|
||
|
||
def _maybe_warn_msix(self):
|
||
"""
|
||
Windows-only, no-op everywhere else: if Claude Desktop looks like an
|
||
MSIX/Store install with a virtualized config, show a persistent banner
|
||
so edits to the plain %APPDATA% path aren't silently lost. (The status
|
||
bar is the wrong home for this: it's rewritten on every action.)
|
||
Defensive on purpose -- this must never block startup or profile load.
|
||
"""
|
||
try:
|
||
warning = core.msix_warning_text()
|
||
except Exception:
|
||
return
|
||
if warning:
|
||
self.warn_banner.setText(f"⚠ {warning}")
|
||
self.warn_banner.setToolTip(warning)
|
||
self.warn_banner.show()
|
||
else:
|
||
self.warn_banner.hide()
|
||
|
||
def add_custom_config(self):
|
||
start = str(core.app_support_base())
|
||
path, _ = QFileDialog.getOpenFileName(
|
||
self, "Select a claude_desktop_config.json", start, "JSON (*.json);;All files (*)"
|
||
)
|
||
if not path:
|
||
return
|
||
prof = core.profile_from_path(path)
|
||
if str(prof.path) in {str(p.path) for p in self.profiles}:
|
||
idx = [str(p.path) for p in self.profiles].index(str(prof.path))
|
||
self.profile_combo.setCurrentIndex(idx)
|
||
return
|
||
self.profiles.append(prof)
|
||
self.profile_combo.addItem(f"{prof.label} (custom)")
|
||
self.profile_combo.setCurrentIndex(self.profile_combo.count() - 1)
|
||
|
||
def _profile_selected(self, idx):
|
||
if 0 <= idx < len(self.profiles):
|
||
self.load_profile(self.profiles[idx])
|
||
|
||
def load_profile(self, profile: core.Profile | None, confirm=False):
|
||
if profile is None:
|
||
return
|
||
if confirm and self.dirty and not self._confirm_discard():
|
||
return
|
||
repaired = False
|
||
try:
|
||
self.full_config = core.load_config(profile.path)
|
||
except Exception as e:
|
||
# Strict parse failed — see if the repair pipeline can salvage it,
|
||
# and let the user decide with full visibility of the changes.
|
||
try:
|
||
cfg, notes, preview = core.repair_config_file(profile.path)
|
||
except Exception:
|
||
QMessageBox.critical(
|
||
self,
|
||
"Could not read config",
|
||
f"{profile.path}\n\n{e}\n\nThe file couldn't be repaired automatically. "
|
||
f"Fix it by hand or pick another install.",
|
||
)
|
||
return
|
||
dlg = RepairDialog(self, profile.path, str(e), notes, preview)
|
||
if not dlg.exec():
|
||
return
|
||
self.full_config = cfg
|
||
repaired = True
|
||
self._loaded_stat = core.config_fingerprint(profile.path)
|
||
self.current_profile = profile
|
||
self.servers = core.extract_servers(self.full_config)
|
||
self.dirty = False
|
||
self.restart_btn.hide()
|
||
self._undo_stack.clear()
|
||
self.undo_btn.setEnabled(False)
|
||
self._health.clear() # health results are per-profile; a fresh load invalidates them
|
||
self._refresh_sets_combo() # sets are per-config; repopulate from the loaded file
|
||
self._refresh_tables(select_index=0 if self.servers else -1)
|
||
self._update_status(saved=False)
|
||
if repaired:
|
||
self._mark_dirty()
|
||
self.status.setText(
|
||
f"{profile.path} · repaired config loaded — press Save to write the "
|
||
f"fix (the original file will be backed up)"
|
||
)
|
||
|
||
# --- table rendering ------------------------------------------------- #
|
||
def _add_row(self, table, master_idx, s):
|
||
r = table.rowCount()
|
||
table.insertRow(r)
|
||
on = QTableWidgetItem()
|
||
on.setFlags(
|
||
Qt.ItemFlag.ItemIsUserCheckable
|
||
| Qt.ItemFlag.ItemIsEnabled
|
||
| Qt.ItemFlag.ItemIsSelectable
|
||
)
|
||
on.setCheckState(Qt.CheckState.Checked if s.enabled else Qt.CheckState.Unchecked)
|
||
table.setItem(r, 0, on)
|
||
name_item = QTableWidgetItem(s.name)
|
||
name_item.setData(Qt.ItemDataRole.UserRole, master_idx) # row -> master list index
|
||
if not s.enabled:
|
||
name_item.setForeground(QColor(MUTED))
|
||
table.setItem(r, 1, name_item)
|
||
type_item = QTableWidgetItem("remote" if s.kind == "remote" else "local")
|
||
if not s.enabled:
|
||
type_item.setForeground(QColor(MUTED))
|
||
table.setItem(r, 2, type_item)
|
||
dep = core.check_dependency(s.data)
|
||
st = QTableWidgetItem(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}")
|
||
st.setForeground(
|
||
QColor(STATUS_COLORS.get(dep["status"], MUTED))
|
||
) # status stays colored even when off
|
||
table.setItem(r, 3, st)
|
||
health_status, health_summary = self._health.get(s.name, (core.HealthStatus.UNTESTED, ""))
|
||
if s.kind == "remote" and s.name not in self._health:
|
||
health_summary = "remote server — use “Test connection” in the editor"
|
||
health_item = QTableWidgetItem(HEALTH_GLYPH.get(health_status, "○"))
|
||
health_item.setForeground(QColor(HEALTH_COLORS.get(health_status, MUTED)))
|
||
health_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
health_item.setToolTip(health_summary or health_status)
|
||
table.setItem(r, 4, health_item)
|
||
self._row_of_index[master_idx] = (table, r)
|
||
|
||
def _refresh_tables(self, select_index=None):
|
||
self._suppress_table = True
|
||
self._suppress_sel = True
|
||
self._row_of_index = {}
|
||
self.active_table.setRowCount(0)
|
||
self.disabled_table.setRowCount(0)
|
||
query = self._filter_query
|
||
n_active = n_disabled = 0
|
||
total_active = total_disabled = 0
|
||
for i, s in enumerate(self.servers):
|
||
if s.enabled:
|
||
total_active += 1
|
||
else:
|
||
total_disabled += 1
|
||
if not core.server_matches_filter(s, query):
|
||
continue
|
||
if s.enabled:
|
||
self._add_row(self.active_table, i, s)
|
||
n_active += 1
|
||
else:
|
||
self._add_row(self.disabled_table, i, s)
|
||
n_disabled += 1
|
||
self.active_table.setVisible(n_active > 0)
|
||
self.active_empty.setVisible(n_active == 0)
|
||
self.active_empty.setText(
|
||
"No matches."
|
||
if query.strip() and total_active and not n_active
|
||
else "No active servers. Add one, or Paste JSON."
|
||
)
|
||
self.disabled_table.setVisible(n_disabled > 0)
|
||
self.disabled_empty.setVisible(n_disabled == 0)
|
||
self.disabled_empty.setText(
|
||
"No matches."
|
||
if query.strip() and total_disabled and not n_disabled
|
||
else "Nothing disabled."
|
||
)
|
||
self._refresh_badges()
|
||
self._suppress_table = False
|
||
self._suppress_sel = False
|
||
|
||
if select_index is not None and select_index in self._row_of_index:
|
||
table, row = self._row_of_index[select_index]
|
||
self._select_in(table, row)
|
||
else:
|
||
self._load_editor_from_selection()
|
||
self._validate()
|
||
|
||
# --- search / filter -------------------------------------------------- #
|
||
def _on_search_changed(self, text):
|
||
self._filter_query = text
|
||
cur = self._current_index()
|
||
self._refresh_tables(select_index=cur if cur >= 0 else None)
|
||
|
||
# --- named server sets (issue #52) ------------------------------------ #
|
||
def _refresh_sets_combo(self, select: str | None = None):
|
||
sets = core.list_server_sets(self.full_config)
|
||
self.sets_combo.blockSignals(True)
|
||
self.sets_combo.clear()
|
||
for name in sorted(sets):
|
||
self.sets_combo.addItem(name)
|
||
if select is not None:
|
||
idx = self.sets_combo.findText(select)
|
||
if idx >= 0:
|
||
self.sets_combo.setCurrentIndex(idx)
|
||
self.sets_combo.blockSignals(False)
|
||
has_sets = bool(sets)
|
||
self.apply_set_btn.setEnabled(has_sets)
|
||
self.del_set_btn.setEnabled(has_sets)
|
||
|
||
def _apply_selected_set(self):
|
||
name = self.sets_combo.currentText()
|
||
sets = core.list_server_sets(self.full_config)
|
||
if name not in sets:
|
||
return
|
||
self._push_undo()
|
||
missing = core.apply_server_set(self.servers, sets[name])
|
||
self._refresh_tables(select_index=self._current_index() if self.servers else None)
|
||
self._mark_dirty()
|
||
on = sum(1 for s in self.servers if s.enabled)
|
||
msg = f"Applied set “{name}” · {on} enabled. Review and Save."
|
||
if missing:
|
||
msg += f" ⚠ no longer in this config: {', '.join(missing)}"
|
||
self.status.setText(msg)
|
||
|
||
def _save_set(self):
|
||
name, ok = QInputDialog.getText(
|
||
self,
|
||
"Save server set",
|
||
"Set name (saves which servers are currently Active):",
|
||
text=self.sets_combo.currentText(),
|
||
)
|
||
name = name.strip()
|
||
if not ok or not name:
|
||
return
|
||
if name in core.list_server_sets(self.full_config) and (
|
||
QMessageBox.question(self, "Set exists", f"Replace set “{name}”?")
|
||
!= QMessageBox.StandardButton.Yes
|
||
):
|
||
return
|
||
members = core.save_server_set(self.full_config, name, self.servers)
|
||
self._refresh_sets_combo(select=name)
|
||
self._mark_dirty() # the set is written on the next Save
|
||
self.status.setText(
|
||
f"Set “{name}” saved ({len(members)} server(s)). Press Save to write it."
|
||
)
|
||
|
||
def _delete_set(self):
|
||
name = self.sets_combo.currentText()
|
||
if not name:
|
||
return
|
||
if (
|
||
QMessageBox.question(self, "Delete set", f"Delete set “{name}”?")
|
||
!= QMessageBox.StandardButton.Yes
|
||
):
|
||
return
|
||
if core.delete_server_set(self.full_config, name):
|
||
self._refresh_sets_combo()
|
||
self._mark_dirty()
|
||
self.status.setText(f"Set “{name}” deleted. Press Save to write the change.")
|
||
|
||
# --- test all (spawn-test every enabled local server) ---------------- #
|
||
def _test_all_servers(self):
|
||
targets = [s for s in self.servers if s.enabled and s.kind == "stdio"]
|
||
if not targets:
|
||
self.status.setText("No enabled local servers to test.")
|
||
return
|
||
self._test_all_queue = list(targets)
|
||
self._test_all_total = len(targets)
|
||
self._test_all_done = 0
|
||
self.test_all_btn.setEnabled(False)
|
||
self.test_all_btn.setText(f"Testing 0/{self._test_all_total}…")
|
||
self._run_next_health_test()
|
||
|
||
def _run_next_health_test(self):
|
||
if not self._test_all_queue:
|
||
self.test_all_btn.setEnabled(True)
|
||
self.test_all_btn.setText("Test all")
|
||
self.status.setText(f"Tested {self._test_all_done} server(s).")
|
||
return
|
||
entry = self._test_all_queue.pop(0)
|
||
self._health_tester = SpawnTester(dict(entry.data), timeout=3.0)
|
||
self._health_tester.done.connect(
|
||
lambda result, name=entry.name: self._on_health_test_done(name, result)
|
||
)
|
||
self._health_tester.start()
|
||
|
||
def _on_health_test_done(self, name, result):
|
||
status, summary = core.health_from_spawn_result(result)
|
||
self._health[name] = (status, summary)
|
||
self._test_all_done += 1
|
||
self.test_all_btn.setText(f"Testing {self._test_all_done}/{self._test_all_total}…")
|
||
self._update_health_cell(name, status, summary)
|
||
self._run_next_health_test()
|
||
|
||
def _update_health_cell(self, name, status, summary):
|
||
idx = next((i for i, s in enumerate(self.servers) if s.name == name), None)
|
||
if idx is None or idx not in self._row_of_index:
|
||
return
|
||
table, row = self._row_of_index[idx]
|
||
item = table.item(row, 4)
|
||
if item is None:
|
||
return
|
||
item.setText(HEALTH_GLYPH.get(status, "○"))
|
||
item.setForeground(QColor(HEALTH_COLORS.get(status, MUTED)))
|
||
item.setToolTip(summary or status)
|
||
|
||
def _section_html(self, title, n, miss, warn):
|
||
base = f"{title} · {n}"
|
||
if miss:
|
||
return f'{base} <span style="color:{BAD};">▲ {miss} broken</span>'
|
||
if warn:
|
||
return f'{base} <span style="color:{WARN};">▲ {warn} PATH-risk</span>'
|
||
return base
|
||
|
||
def _refresh_badges(self):
|
||
n_a = n_d = a_miss = a_warn = d_miss = d_warn = 0
|
||
a_names, d_names = [], []
|
||
for s in self.servers:
|
||
st = core.check_dependency(s.data)["status"]
|
||
if s.enabled:
|
||
n_a += 1
|
||
if st == "missing":
|
||
a_miss += 1
|
||
a_names.append(s.name)
|
||
elif st == "warn":
|
||
a_warn += 1
|
||
a_names.append(s.name)
|
||
else:
|
||
n_d += 1
|
||
if st == "missing":
|
||
d_miss += 1
|
||
d_names.append(s.name)
|
||
elif st == "warn":
|
||
d_warn += 1
|
||
d_names.append(s.name)
|
||
self.active_label.setText(self._section_html("Active", n_a, a_miss, a_warn))
|
||
self.active_label.setToolTip(("Needs attention: " + ", ".join(a_names)) if a_names else "")
|
||
self.disabled_label.setText(self._section_html("⊘ Disabled", n_d, d_miss, d_warn))
|
||
self.disabled_label.setToolTip(
|
||
("Needs attention: " + ", ".join(d_names)) if d_names else ""
|
||
)
|
||
|
||
def _select_in(self, table, row):
|
||
self._suppress_sel = True
|
||
other = self.disabled_table if table is self.active_table else self.active_table
|
||
other.clearSelection()
|
||
other.setCurrentCell(-1, -1)
|
||
self._suppress_sel = False
|
||
self._focused_table = table
|
||
table.selectRow(row) # fires _on_selection -> loads editor
|
||
|
||
# --- selection across the two tables -------------------------------- #
|
||
def _on_selection(self, table):
|
||
if self._suppress_sel:
|
||
return
|
||
if table.currentRow() < 0:
|
||
return
|
||
self._suppress_sel = True
|
||
other = self.disabled_table if table is self.active_table else self.active_table
|
||
other.clearSelection()
|
||
other.setCurrentCell(-1, -1)
|
||
self._suppress_sel = False
|
||
self._focused_table = table
|
||
self._load_editor_from_selection()
|
||
|
||
def _current_index(self) -> int:
|
||
t = self._focused_table
|
||
if t is None:
|
||
return -1
|
||
r = t.currentRow()
|
||
if r < 0:
|
||
return -1
|
||
item = t.item(r, 1)
|
||
if item is None:
|
||
return -1
|
||
idx = item.data(Qt.ItemDataRole.UserRole)
|
||
return idx if idx is not None else -1
|
||
|
||
def _load_editor_from_selection(self):
|
||
idx = self._current_index()
|
||
entry = self.servers[idx] if 0 <= idx < len(self.servers) else None
|
||
self.editor.load_entry(entry)
|
||
self.copy_btn.setEnabled(entry is not None and len(self.profiles) > 1)
|
||
self.dup_btn.setEnabled(entry is not None)
|
||
self.del_btn.setEnabled(entry is not None)
|
||
|
||
def _table_item_changed(self, item: QTableWidgetItem):
|
||
if self._suppress_table or item.column() != 0:
|
||
return
|
||
table = item.tableWidget()
|
||
name_item = table.item(item.row(), 1)
|
||
if name_item is None:
|
||
return
|
||
idx = name_item.data(Qt.ItemDataRole.UserRole)
|
||
if idx is None or not (0 <= idx < len(self.servers)):
|
||
return
|
||
self._push_undo()
|
||
self.servers[idx].enabled = item.checkState() == Qt.CheckState.Checked
|
||
# Toggling moves the server to the other section; keep it selected there.
|
||
self._refresh_tables(select_index=idx)
|
||
self._mark_dirty()
|
||
|
||
# --- editor change --------------------------------------------------- #
|
||
def _editor_changed(self):
|
||
idx = self._current_index()
|
||
if not (0 <= idx < len(self.servers)):
|
||
return
|
||
entry = self.servers[idx]
|
||
old_name = entry.name
|
||
entry.name = self.editor.current_name()
|
||
entry.data = self.editor.dump_data()
|
||
# The server stays in its section (enable state unchanged), so update
|
||
# its existing row in place rather than re-rendering.
|
||
# An edit invalidates any cached "Test all" result -- the server that
|
||
# was spawn-tested no longer matches what's on disk once saved. Pop
|
||
# both names: the old one (so a rename doesn't leave a stale entry
|
||
# for whoever takes that name next) and the new one (so we don't
|
||
# inherit a stale result cached under the name being renamed to).
|
||
self._health.pop(old_name, None)
|
||
self._health.pop(entry.name, None)
|
||
loc = self._row_of_index.get(idx)
|
||
if loc:
|
||
table, row = loc
|
||
self._suppress_table = True
|
||
table.item(row, 1).setText(entry.name)
|
||
table.item(row, 2).setText("remote" if entry.kind == "remote" else "local")
|
||
dep = core.check_dependency(entry.data)
|
||
st = table.item(row, 3)
|
||
st.setText(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}")
|
||
st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED)))
|
||
health_item = table.item(row, 4)
|
||
if health_item is not None:
|
||
health_item.setText(HEALTH_GLYPH.get(core.HealthStatus.UNTESTED, "○"))
|
||
health_item.setForeground(
|
||
QColor(HEALTH_COLORS.get(core.HealthStatus.UNTESTED, MUTED))
|
||
)
|
||
health_item.setToolTip("Not tested since last edit.")
|
||
self._suppress_table = False
|
||
self._refresh_badges()
|
||
self._mark_dirty()
|
||
|
||
# --- server actions -------------------------------------------------- #
|
||
def add_server(self):
|
||
self._push_undo()
|
||
base = "new-server"
|
||
name = base
|
||
n = 2
|
||
existing = {s.name for s in self.servers}
|
||
while name in existing:
|
||
name = f"{base}-{n}"
|
||
n += 1
|
||
self.servers.append(core.ServerEntry(name, {"command": "npx", "args": []}, True))
|
||
self._refresh_tables(select_index=len(self.servers) - 1)
|
||
self._mark_dirty()
|
||
self.editor.name.setFocus()
|
||
self.editor.name.selectAll()
|
||
|
||
def duplicate_server(self):
|
||
idx = self._current_index()
|
||
if not (0 <= idx < len(self.servers)):
|
||
return
|
||
self._push_undo()
|
||
src = self.servers[idx]
|
||
existing = {s.name for s in self.servers}
|
||
name = f"{src.name}-copy"
|
||
n = 2
|
||
while name in existing:
|
||
name = f"{src.name}-copy-{n}"
|
||
n += 1
|
||
self.servers.insert(idx + 1, core.ServerEntry(name, dict(src.data), src.enabled))
|
||
self._refresh_tables(select_index=idx + 1)
|
||
self._mark_dirty()
|
||
|
||
def delete_server(self):
|
||
idx = self._current_index()
|
||
if not (0 <= idx < len(self.servers)):
|
||
return
|
||
name = self.servers[idx].name
|
||
if (
|
||
QMessageBox.question(self, "Delete server", f"Remove “{name}”?")
|
||
!= QMessageBox.StandardButton.Yes
|
||
):
|
||
return
|
||
self._push_undo()
|
||
del self.servers[idx]
|
||
self._refresh_tables(select_index=min(idx, len(self.servers) - 1))
|
||
self._mark_dirty()
|
||
|
||
def _import_server(self, name: str, data: dict) -> tuple[bool, bool]:
|
||
"""
|
||
Add a pasted/dropped `name`/`data` server to self.servers, resolving
|
||
a name collision with an explicit prompt (never a silent overwrite).
|
||
|
||
Returns (added, replaced).
|
||
"""
|
||
existing = {s.name: i for i, s in enumerate(self.servers)}
|
||
if name in existing:
|
||
ans = QMessageBox.question(
|
||
self,
|
||
"Server exists",
|
||
f"“{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if ans == QMessageBox.StandardButton.Yes:
|
||
self.servers[existing[name]].data = data
|
||
return False, True
|
||
name = core.resolve_name_collision(name, {s.name for s in self.servers})
|
||
self.servers.append(core.ServerEntry(name, data, True))
|
||
return True, False
|
||
|
||
def paste_json(self):
|
||
dlg = PasteDialog(self)
|
||
if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.result_servers:
|
||
return
|
||
self._push_undo()
|
||
added, replaced = 0, 0
|
||
for name, data in dlg.result_servers.items():
|
||
a, r = self._import_server(name, data)
|
||
added += int(a)
|
||
replaced += int(r)
|
||
self._refresh_tables(select_index=len(self.servers) - 1)
|
||
self._mark_dirty()
|
||
self.status.setText(f"Imported {added} added, {replaced} replaced. Review and Save.")
|
||
|
||
def copy_to_menu(self):
|
||
idx = self._current_index()
|
||
if not (0 <= idx < len(self.servers)):
|
||
return
|
||
menu = QMenu(self)
|
||
for p in self.profiles:
|
||
if self.current_profile and str(p.path) == str(self.current_profile.path):
|
||
continue
|
||
act = QAction(p.label, self)
|
||
act.triggered.connect(lambda _=False, prof=p: self._copy_to(prof, idx))
|
||
menu.addAction(act)
|
||
if menu.isEmpty():
|
||
return
|
||
self.copy_btn.setMenu(menu)
|
||
self.copy_btn.showMenu()
|
||
|
||
def _copy_to(self, dest: core.Profile, idx: int):
|
||
src = self.servers[idx]
|
||
try:
|
||
dest_cfg = core.load_config(dest.path)
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Copy failed", f"Couldn't read {dest.label}:\n{e}")
|
||
return
|
||
existing = core.extract_servers(dest_cfg)
|
||
names = {s.name for s in existing}
|
||
if src.name in names:
|
||
ans = QMessageBox.question(
|
||
self,
|
||
"Exists in destination",
|
||
f"“{src.name}” already exists in {dest.label}. Overwrite it there?",
|
||
)
|
||
if ans != QMessageBox.StandardButton.Yes:
|
||
return
|
||
existing = [s for s in existing if s.name != src.name]
|
||
existing.append(core.ServerEntry(src.name, dict(src.data), True))
|
||
core.apply_servers(dest_cfg, existing)
|
||
try:
|
||
backup = core.write_config(dest.path, dest_cfg)
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Copy failed", f"Couldn't write {dest.label}:\n{e}")
|
||
return
|
||
bnote = f" (backup: {backup.name})" if backup else ""
|
||
self.status.setText(
|
||
f"Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply."
|
||
)
|
||
|
||
# --- save ------------------------------------------------------------ #
|
||
def _validate(self) -> bool:
|
||
problems = core.validate_servers(self.servers)
|
||
if problems:
|
||
self.validation_lbl.setText(f"⚠ {problems[0]}")
|
||
self.validation_lbl.setStyleSheet(f"color: {WARN};")
|
||
self.save_btn.setEnabled(False)
|
||
return False
|
||
lint_warnings = core.lint_servers(self.servers)
|
||
if lint_warnings:
|
||
self.validation_lbl.setText(f"⚠ {lint_warnings[0]}")
|
||
self.validation_lbl.setStyleSheet(f"color: {WARN};")
|
||
else:
|
||
self.validation_lbl.setText("✓ valid")
|
||
self.validation_lbl.setStyleSheet(f"color: {GOOD};")
|
||
self.save_btn.setEnabled(self.dirty)
|
||
return True
|
||
|
||
def _save_shortcut(self):
|
||
"""Ctrl+S / Cmd+S handler — only fires when the Save button itself
|
||
would accept a click, so the shortcut can't bypass validation/dirty
|
||
gating."""
|
||
if self.save_btn.isEnabled():
|
||
self.save()
|
||
|
||
def save(self):
|
||
if not self.current_profile:
|
||
return
|
||
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.
|
||
# Compare mtime AND size (not mtime alone) so a concurrent external write
|
||
# that lands within the mtime resolution window, or that restores the
|
||
# original mtime, still gets caught.
|
||
disk_stat = core.config_fingerprint(self.current_profile.path)
|
||
if (
|
||
disk_stat is not None
|
||
and self._loaded_stat is not None
|
||
and disk_stat != self._loaded_stat
|
||
):
|
||
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_stat = core.config_fingerprint(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."
|
||
)
|
||
self._offer_restart_button()
|
||
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_stat = core.config_fingerprint(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"Saved {self.current_profile.path}{bnote} · Restart {self.current_profile.label} to apply."
|
||
)
|
||
self._offer_restart_button()
|
||
|
||
# --- restart Claude Desktop (issue #9) -------------------------------- #
|
||
def _offer_restart_button(self):
|
||
"""Show the 'Restart Claude Desktop' button after a successful save,
|
||
but only when the just-saved profile is Claude Desktop -- restarting
|
||
makes no sense for Claude Code, which has no GUI process to bounce --
|
||
and only on platforms where Claude Desktop exists (never Linux, where
|
||
'claude' is the Claude Code CLI)."""
|
||
if (
|
||
self.current_profile
|
||
and core.profile_targets_claude_desktop(self.current_profile)
|
||
and core.restart_supported()
|
||
):
|
||
self.restart_btn.show()
|
||
else:
|
||
self.restart_btn.hide()
|
||
|
||
def _restart_claude_desktop(self):
|
||
self.restart_btn.setEnabled(False)
|
||
self.restart_btn.setText("Restarting…")
|
||
# Held on self (MainWindow outlives the worker); replaced only after
|
||
# done re-enables the button, so a running thread is never dropped.
|
||
self._restart_worker = RestartWorker()
|
||
self._restart_worker.done.connect(self._on_restart_done)
|
||
self._restart_worker.start()
|
||
|
||
def _on_restart_done(self, result):
|
||
self.restart_btn.setEnabled(True)
|
||
self.restart_btn.setText("Restart Claude Desktop")
|
||
self.restart_btn.hide()
|
||
if result.success:
|
||
self.status.setText(f"{self.status.text()} · {result.detail}")
|
||
else:
|
||
QMessageBox.warning(self, "Restart failed", result.detail)
|
||
|
||
def _restore_from_backup(self):
|
||
if not self.current_profile:
|
||
return
|
||
if self.dirty and not self._confirm_discard():
|
||
return
|
||
# full_config is the right base: discarded edits were server changes;
|
||
# non-server keys from the in-memory state are what we want to preserve.
|
||
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
|
||
self.restart_btn.hide()
|
||
self._validate()
|
||
self._update_status(saved=False)
|
||
|
||
def _update_status(self, saved):
|
||
if self.current_profile:
|
||
n = len(self.servers)
|
||
on = sum(1 for s in self.servers if s.enabled)
|
||
d = " · unsaved changes" if self.dirty else ""
|
||
self.status.setText(f"{self.current_profile.path} · {on}/{n} enabled{d}")
|
||
|
||
def _confirm_discard(self) -> bool:
|
||
return (
|
||
QMessageBox.question(
|
||
self, "Discard changes?", "You have unsaved changes. Discard them?"
|
||
)
|
||
== QMessageBox.StandardButton.Yes
|
||
)
|
||
|
||
# --- drag & drop a .json file to import ------------------------------ #
|
||
def dragEnterEvent(self, e):
|
||
if e.mimeData().hasUrls() and any(
|
||
u.toLocalFile().endswith(".json") for u in e.mimeData().urls()
|
||
):
|
||
e.acceptProposedAction()
|
||
|
||
def dropEvent(self, e):
|
||
total_added, total_replaced, files_imported = 0, 0, 0
|
||
undo_pushed = False
|
||
for u in e.mimeData().urls():
|
||
path = u.toLocalFile()
|
||
if not path.endswith(".json"):
|
||
continue
|
||
p = Path(path)
|
||
if p.stat().st_size > MAX_DROP_IMPORT_BYTES:
|
||
QMessageBox.warning(
|
||
self,
|
||
"File too large",
|
||
f"{p.name}:\nFile exceeds the 5 MB import limit and was skipped.",
|
||
)
|
||
continue
|
||
text = p.read_text(encoding="utf-8")
|
||
try:
|
||
servers = core.parse_pasted_json(text)
|
||
except Exception as ex:
|
||
QMessageBox.warning(self, "Couldn't import", f"{p.name}:\n{ex}")
|
||
continue
|
||
if not undo_pushed:
|
||
self._push_undo()
|
||
undo_pushed = True
|
||
added, replaced = 0, 0
|
||
for name, data in servers.items():
|
||
a, r = self._import_server(name, data)
|
||
added += int(a)
|
||
replaced += int(r)
|
||
total_added += added
|
||
total_replaced += replaced
|
||
files_imported += 1
|
||
if files_imported:
|
||
self._refresh_tables(select_index=len(self.servers) - 1)
|
||
self._mark_dirty()
|
||
self.status.setText(
|
||
f"Imported {total_added} added, {total_replaced} replaced from "
|
||
f"{files_imported} file(s). Review and Save."
|
||
)
|
||
|
||
def closeEvent(self, e):
|
||
if self.dirty and not self._confirm_discard():
|
||
e.ignore()
|
||
return
|
||
self._save_layout()
|
||
e.accept()
|
||
|
||
|
||
def main():
|
||
if sys.platform == "win32":
|
||
# Without an explicit AppUserModelID, Windows taskbar groups the app
|
||
# under the default host/Python icon instead of our own window icon.
|
||
try:
|
||
import ctypes
|
||
|
||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
|
||
"io.avezzano.better-claude-config"
|
||
)
|
||
except Exception:
|
||
pass
|
||
app = QApplication(sys.argv)
|
||
app.setApplicationName("Better Claude Config")
|
||
app.setApplicationDisplayName("Better Claude Config")
|
||
icon = _app_icon()
|
||
if not icon.isNull():
|
||
app.setWindowIcon(icon)
|
||
app.setStyleSheet(STYLESHEET)
|
||
win = MainWindow()
|
||
win.show()
|
||
sys.exit(app.exec())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|