feat: user-resizable panes with persisted layout
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 6s
CI / Tests (py3.12) (push) Successful in 6s

- Horizontal splitter between the server list and the editor panel
- Vertical splitter between the Active and Disabled tables (drops the
  fixed 170px cap on the disabled list)
- Editor fields (args, env/header tables) now grow with the window
  instead of being pinned to fixed max heights
- Splitter positions and window geometry persist across launches via
  QSettings; handles highlight on hover/drag
This commit is contained in:
AJ
2026-07-01 23:51:40 -04:00
parent 6205c85b61
commit d5c89e9602
+69 -21
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Qt, QThread, QTimer, Signal from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QColor, QGuiApplication from PySide6.QtGui import QAction, QColor, QGuiApplication
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
QMessageBox, QMessageBox,
QPlainTextEdit, QPlainTextEdit,
QPushButton, QPushButton,
QSplitter,
QStackedWidget, QStackedWidget,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
@@ -98,6 +99,9 @@ QTableWidget#disabledTable::item:selected {{ background: {ACCENT}; color: #1a120
QPlainTextEdit#diag {{ font-family: "SF Mono", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace; QPlainTextEdit#diag {{ font-family: "SF Mono", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace;
font-size: 12px; background: #16181d; border: 1px solid {BORDER}; border-radius: 8px; }} font-size: 12px; background: #16181d; border: 1px solid {BORDER}; border-radius: 8px; }}
QFrame#diagCard {{ background: transparent; border: none; }} 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; }}
""" """
@@ -136,9 +140,9 @@ class KeyValueTable(QWidget):
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.table.verticalHeader().setVisible(False) self.table.verticalHeader().setVisible(False)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setMaximumHeight(150) self.table.setMinimumHeight(90)
self.table.itemChanged.connect(self._changed) self.table.itemChanged.connect(self._changed)
lay.addWidget(self.table) lay.addWidget(self.table, 1)
row = QHBoxLayout() row = QHBoxLayout()
add = QPushButton("+ Add") add = QPushButton("+ Add")
rem = QPushButton(" Remove") rem = QPushButton(" Remove")
@@ -281,7 +285,7 @@ class ServerEditor(QFrame):
self.stack = QStackedWidget() self.stack = QStackedWidget()
self.stack.addWidget(self._build_stdio_page()) self.stack.addWidget(self._build_stdio_page())
self.stack.addWidget(self._build_remote_page()) self.stack.addWidget(self._build_remote_page())
outer.addWidget(self.stack) outer.addWidget(self.stack, 1)
# Dependency status row # Dependency status row
dep = QHBoxLayout() dep = QHBoxLayout()
@@ -329,7 +333,6 @@ class ServerEditor(QFrame):
dlay.addLayout(crow) dlay.addLayout(crow)
self.diag_card.setVisible(False) self.diag_card.setVisible(False)
outer.addWidget(self.diag_card) outer.addWidget(self.diag_card)
outer.addStretch()
self.setEnabled(False) self.setEnabled(False)
@@ -353,14 +356,14 @@ class ServerEditor(QFrame):
self.args.setPlaceholderText( self.args.setPlaceholderText(
"-y\n@modelcontextprotocol/server-filesystem\n/Users/you/Documents" "-y\n@modelcontextprotocol/server-filesystem\n/Users/you/Documents"
) )
self.args.setMaximumHeight(120) self.args.setMinimumHeight(70)
self.args.textChanged.connect(self._emit) self.args.textChanged.connect(self._emit)
v.addWidget(self.args) v.addWidget(self.args, 1)
v.addWidget(self._lbl("Environment variables")) v.addWidget(self._lbl("Environment variables"))
self.env = KeyValueTable( self.env = KeyValueTable(
"Variable", "Value", on_change=self._emit, before_change=self._before_change "Variable", "Value", on_change=self._emit, before_change=self._before_change
) )
v.addWidget(self.env) v.addWidget(self.env, 1)
return w return w
def _build_remote_page(self): def _build_remote_page(self):
@@ -382,7 +385,7 @@ class ServerEditor(QFrame):
self.headers = KeyValueTable( self.headers = KeyValueTable(
"Header", "Value", on_change=self._emit, before_change=self._before_change "Header", "Value", on_change=self._emit, before_change=self._before_change
) )
v.addWidget(self.headers) v.addWidget(self.headers, 1)
return w return w
# --- model <-> form -------------------------------------------------- # # --- model <-> form -------------------------------------------------- #
@@ -655,12 +658,18 @@ class MainWindow(QMainWindow):
root.addLayout(self._build_topbar()) root.addLayout(self._build_topbar())
split = QHBoxLayout() # User-draggable divider between the server list and the editor.
split.setSpacing(12) split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(self._build_left(), 3) split.setChildrenCollapsible(False)
split.setHandleWidth(10)
split.addWidget(self._build_left())
self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo) self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo)
split.addWidget(self.editor, 4) split.addWidget(self.editor)
root.addLayout(split, 1) split.setStretchFactor(0, 3)
split.setStretchFactor(1, 4)
split.setSizes([390, 520])
self.hsplit = split
root.addWidget(split, 1)
root.addLayout(self._build_actionbar()) root.addLayout(self._build_actionbar())
@@ -668,8 +677,25 @@ class MainWindow(QMainWindow):
self.status.setObjectName("statusbar") self.status.setObjectName("statusbar")
root.addWidget(self.status) root.addWidget(self.status)
self._restore_layout()
self.reload_profiles() self.reload_profiles()
# --- 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 --------------------------------------------------------- # # --- top bar --------------------------------------------------------- #
def _build_topbar(self): def _build_topbar(self):
bar = QHBoxLayout() bar = QHBoxLayout()
@@ -722,27 +748,48 @@ class MainWindow(QMainWindow):
head.setObjectName("h1") head.setObjectName("h1")
v.addWidget(head) v.addWidget(head)
# 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 = QLabel("Active")
self.active_label.setObjectName("section") self.active_label.setObjectName("section")
v.addWidget(self.active_label) av.addWidget(self.active_label)
self.active_table = self._make_server_table() self.active_table = self._make_server_table()
v.addWidget(self.active_table, 3) av.addWidget(self.active_table, 1)
self.active_empty = QLabel("No active servers. Add one, or Paste JSON.") self.active_empty = QLabel("No active servers. Add one, or Paste JSON.")
self.active_empty.setObjectName("placeholder") self.active_empty.setObjectName("placeholder")
self.active_empty.setAlignment(Qt.AlignmentFlag.AlignCenter) self.active_empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.active_empty.hide() self.active_empty.hide()
v.addWidget(self.active_empty) 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 = QLabel("⊘ Disabled")
self.disabled_label.setObjectName("sectionDisabled") self.disabled_label.setObjectName("sectionDisabled")
v.addWidget(self.disabled_label) dv.addWidget(self.disabled_label)
self.disabled_table = self._make_server_table(object_name="disabledTable") self.disabled_table = self._make_server_table(object_name="disabledTable")
self.disabled_table.setMaximumHeight(170) dv.addWidget(self.disabled_table, 1)
v.addWidget(self.disabled_table, 1)
self.disabled_empty = QLabel("Nothing disabled.") self.disabled_empty = QLabel("Nothing disabled.")
self.disabled_empty.setObjectName("placeholder") self.disabled_empty.setObjectName("placeholder")
self.disabled_empty.setAlignment(Qt.AlignmentFlag.AlignCenter) self.disabled_empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
v.addWidget(self.disabled_empty) 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 return card
# --- bottom action bar ---------------------------------------------- # # --- bottom action bar ---------------------------------------------- #
@@ -1255,6 +1302,7 @@ class MainWindow(QMainWindow):
if self.dirty and not self._confirm_discard(): if self.dirty and not self._confirm_discard():
e.ignore() e.ignore()
return return
self._save_layout()
e.accept() e.accept()