Fix PATH check, add undo, and popup dialog for env/header vars

PATH check:
- Add system_path() that reads /etc/paths + /etc/paths.d/* + well-known
  package manager dirs (/opt/homebrew/bin, /usr/local/bin, /opt/local/bin)
  so the ok/warn distinction is consistent whether BCC runs from terminal
  or as a .app — /opt/homebrew/bin/npx now correctly shows as ok
- augmented_path() now uses system_path() as its base and also includes
  the current process PATH (handles CI / container setups)
- diagnostics_text() uses system_path() to mark user-specific dirs with +

Undo:
- _push_undo() / _undo() snapshot-based undo with 50-entry stack
- Undo button in action bar + Ctrl+Z shortcut
- Hooked into: add server, duplicate, delete, paste JSON, enable/disable
  toggle, drag-drop import, and env/header row add/remove (via
  before_change callback chain through ServerEditor → KeyValueTable)
- Undo stack resets on profile load

Variable/header entry:
- KeyValueTable.+ Add now opens a popup dialog with labeled fields
  (key name + OK disabled until non-empty) instead of inserting an
  invisible blank row — applies consistently to both env and headers tables
- Pressing Enter in key field moves to value; Enter in value submits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AJ Avezzano
2026-06-29 15:34:18 -04:00
parent dd7557ea62
commit 2e33850fe3
2 changed files with 221 additions and 43 deletions
+121 -22
View File
@@ -103,9 +103,12 @@ class ConnTester(QThread):
# Small reusable: key/value editor (for env and headers)
# --------------------------------------------------------------------------- #
class KeyValueTable(QWidget):
def __init__(self, key_label="Key", val_label="Value", on_change=None):
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)
@@ -133,17 +136,69 @@ class KeyValueTable(QWidget):
self._on_change()
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)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok)
ok_btn.setObjectName("primary")
ok_btn.setEnabled(False)
key_edit.textChanged.connect(lambda t: ok_btn.setEnabled(bool(t.strip())))
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:
return
if self._before_change:
self._before_change()
r = self.table.rowCount()
self.table.insertRow(r)
self.table.setItem(r, 0, QTableWidgetItem(""))
self.table.setItem(r, 1, QTableWidgetItem(""))
self.table.setItem(r, 0, QTableWidgetItem(k))
self.table.setItem(r, 1, QTableWidgetItem(val))
self._changed()
def _remove_row(self):
r = self.table.currentRow()
if r >= 0:
self.table.removeRow(r)
self._changed()
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)
@@ -171,10 +226,11 @@ class KeyValueTable(QWidget):
# The server editor panel (right side)
# --------------------------------------------------------------------------- #
class ServerEditor(QFrame):
def __init__(self, on_change):
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
@@ -279,7 +335,8 @@ class ServerEditor(QFrame):
self.args.textChanged.connect(self._emit)
v.addWidget(self.args)
v.addWidget(self._lbl("Environment variables"))
self.env = KeyValueTable("Variable", "Value", on_change=self._emit)
self.env = KeyValueTable("Variable", "Value", on_change=self._emit,
before_change=self._before_change)
v.addWidget(self.env)
return w
@@ -299,7 +356,8 @@ class ServerEditor(QFrame):
self.transport.currentIndexChanged.connect(self._emit)
v.addWidget(self.transport)
v.addWidget(self._lbl("Headers"))
self.headers = KeyValueTable("Header", "Value", on_change=self._emit)
self.headers = KeyValueTable("Header", "Value", on_change=self._emit,
before_change=self._before_change)
v.addWidget(self.headers)
return w
@@ -447,10 +505,10 @@ class ServerEditor(QFrame):
if not url:
return
self.test_btn.setEnabled(False)
self.test_btn.setText("Testing…")
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.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)
@@ -478,14 +536,14 @@ class PasteDialog(QDialog):
v = QVBoxLayout(self)
info = QLabel(
"Paste a config snippet from any MCP doc. Accepts a full "
"{\"mcpServers\": {}} block, just the inner {\"name\": {}} map, "
"{\"mcpServers\": {...}} block, just the inner {\"name\": {...}} map, "
"or a single bare server object."
)
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}')
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.setStyleSheet(f"color: {BAD};")
@@ -526,6 +584,7 @@ class MainWindow(QMainWindow):
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
central = QWidget()
self.setCentralWidget(central)
@@ -538,7 +597,8 @@ class MainWindow(QMainWindow):
split = QHBoxLayout()
split.setSpacing(12)
split.addWidget(self._build_left(), 3)
self.editor = ServerEditor(on_change=self._editor_changed)
self.editor = ServerEditor(on_change=self._editor_changed,
before_change=self._push_undo)
split.addWidget(self.editor, 4)
root.addLayout(split, 1)
@@ -560,7 +620,7 @@ class MainWindow(QMainWindow):
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 = QPushButton("Add config...")
add_cfg.clicked.connect(self.add_custom_config)
bar.addWidget(add_cfg)
reload_btn = QPushButton("Reload")
@@ -632,20 +692,51 @@ class MainWindow(QMainWindow):
self.dup_btn = QPushButton("Duplicate")
self.del_btn = QPushButton("Delete")
self.del_btn.setObjectName("danger")
self.paste_btn = QPushButton("Paste JSON")
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.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)
for b in (self.add_btn, self.dup_btn, self.del_btn, self.paste_btn, self.copy_btn):
self.undo_btn.clicked.connect(self._undo)
for b in (self.add_btn, self.dup_btn, self.del_btn, self.paste_btn,
self.copy_btn, self.undo_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)
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.")
# --- profiles -------------------------------------------------------- #
def reload_profiles(self):
discovered = core.discover_profiles()
@@ -664,7 +755,7 @@ class MainWindow(QMainWindow):
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.status.setText('No Claude installs found. Use "Add config…" to point at one.')
def add_custom_config(self):
start = str(core.app_support_base())
@@ -702,6 +793,8 @@ class MainWindow(QMainWindow):
self.current_profile = profile
self.servers = core.extract_servers(self.full_config)
self.dirty = False
self._undo_stack.clear()
self.undo_btn.setEnabled(False)
self._refresh_tables(select_index=0 if self.servers else -1)
self._update_status(saved=False)
@@ -841,6 +934,7 @@ class MainWindow(QMainWindow):
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)
@@ -872,6 +966,7 @@ class MainWindow(QMainWindow):
# --- server actions -------------------------------------------------- #
def add_server(self):
self._push_undo()
base = "new-server"
name = base
n = 2
@@ -889,6 +984,7 @@ class MainWindow(QMainWindow):
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"
@@ -905,8 +1001,9 @@ class MainWindow(QMainWindow):
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:
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()
@@ -915,13 +1012,14 @@ class MainWindow(QMainWindow):
dlg = PasteDialog(self)
if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.result_servers:
return
self._push_undo()
added, replaced = 0, 0
existing = {s.name: i for i, s in enumerate(self.servers)}
for name, data in dlg.result_servers.items():
if name in existing:
ans = QMessageBox.question(
self, "Server exists",
f"{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)",
f'{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if ans == QMessageBox.StandardButton.Yes:
@@ -969,7 +1067,7 @@ class MainWindow(QMainWindow):
if src.name in names:
ans = QMessageBox.question(
self, "Exists in destination",
f"{src.name}” already exists in {dest.label}. Overwrite it there?"
f'{src.name}” already exists in {dest.label}. Overwrite it there?'
)
if ans != QMessageBox.StandardButton.Yes:
return
@@ -982,7 +1080,7 @@ class MainWindow(QMainWindow):
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.")
self.status.setText(f'Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply.')
# --- save ------------------------------------------------------------ #
def _validate(self) -> bool:
@@ -1052,6 +1150,7 @@ class MainWindow(QMainWindow):
except Exception as ex:
QMessageBox.warning(self, "Couldn't import", f"{Path(path).name}:\n{ex}")
continue
self._push_undo()
for name, data in servers.items():
names = {s.name for s in self.servers}
if name in names: