release: v1.2.0 — About+update checker, log viewer, restart button, dup-name fix, stale-file hardening #26

Merged
the_og merged 34 commits from release/v1.2.0 into main 2026-07-08 11:34:35 -04:00
Showing only changes of commit 2b3843a714 - Show all commits
+130
View File
@@ -374,6 +374,9 @@ class ServerEditor(QFrame):
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)
@@ -384,6 +387,7 @@ class ServerEditor(QFrame):
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)
@@ -728,6 +732,13 @@ class ServerEditor(QFrame):
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
@@ -1090,6 +1101,125 @@ class PasteDialog(QDialog):
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)
# --------------------------------------------------------------------------- #
# Main window
# --------------------------------------------------------------------------- #