"""
ui/windows/missing_plugins_dialog.py
Shown when loading a profile that requires plugins that are either not
installed or installed but missing their dependencies.
Each row is one of two kinds:
'plugin' — plugin not installed → Download (if source_url) + Install from file…
'deps' — plugin installed but deps absent → Reinstall from file…
"""
import os
import tempfile
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFrame, QFileDialog, QMessageBox, QApplication,
)
from PyQt6.QtCore import Qt
class MissingPluginsDialog(QDialog):
def __init__(self, missing_manifests: list, plugin_mgr, parent=None):
super().__init__(parent, Qt.WindowType.Dialog)
self._plugin_mgr = plugin_mgr
self._missing = missing_manifests
self._install_btns: dict = {} # plugin_id -> "Install from file…" button
self._download_btns: dict = {} # plugin_id -> "Download" button (optional)
self._status_lbls: dict = {} # plugin_id -> status QLabel
self.setWindowTitle("Missing Plugins")
self.setMinimumWidth(540)
self._build()
# ── Build ─────────────────────────────────────────────────────────────
def _build(self):
root = QVBoxLayout(self)
root.setSpacing(12)
root.setContentsMargins(16, 16, 16, 16)
intro = QLabel(
"This profile requires plugins that are not ready on this machine.\n"
"Fix the issues below, then click Continue to finish loading."
)
intro.setWordWrap(True)
root.addWidget(intro)
root.addWidget(_hline())
for pm in self._missing:
root.addLayout(self._plugin_row(pm))
root.addWidget(_hline())
btn_row = QHBoxLayout()
btn_row.addStretch()
cancel_btn = QPushButton("Cancel load")
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(cancel_btn)
self._continue_btn = QPushButton("Continue")
self._continue_btn.setObjectName("applyButton")
self._continue_btn.clicked.connect(self.accept)
btn_row.addWidget(self._continue_btn)
root.addLayout(btn_row)
self._refresh_continue_btn()
def _plugin_row(self, pm: dict) -> QHBoxLayout:
row = QHBoxLayout(); row.setSpacing(8)
# Left: name + detail
info_col = QVBoxLayout(); info_col.setSpacing(2)
name_lbl = QLabel(f"{pm.get('name', pm['plugin_id'])}"
f" v{pm.get('version', '?')}")
info_col.addWidget(name_lbl)
if pm.get("kind") == "deps":
detail = "Missing packages: " + ", ".join(pm.get("missing_deps", []))
elif pm.get("description"):
detail = pm["description"]
else:
detail = ""
if detail:
dl = QLabel(detail)
dl.setObjectName("traceSource"); dl.setWordWrap(True)
info_col.addWidget(dl)
row.addLayout(info_col, 1)
# Status label
is_deps = pm.get("kind") == "deps"
status_lbl = QLabel("Deps missing" if is_deps else "Not installed")
status_lbl.setObjectName("traceSource")
status_lbl.setFixedWidth(130)
status_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._status_lbls[pm["plugin_id"]] = status_lbl
row.addWidget(status_lbl)
# Download button — only for missing plugins with a source_url
source_url = pm.get("source_url", "")
if source_url and not is_deps:
dl_btn = QPushButton("Download")
dl_btn.setObjectName("configButton")
dl_btn.setFixedWidth(90)
dl_btn.setToolTip(f"Download from:\n{source_url}")
dl_btn.clicked.connect(lambda _, p=pm: self._download(p))
self._download_btns[pm["plugin_id"]] = dl_btn
row.addWidget(dl_btn)
# Install / Reinstall from file button
btn_label = "Reinstall from file…" if is_deps else "Install from file…"
inst_btn = QPushButton(btn_label)
inst_btn.setObjectName("configButton")
inst_btn.setFixedWidth(150)
if is_deps:
inst_btn.setToolTip("Reinstall with a zip that bundles deps in vendor/")
inst_btn.clicked.connect(lambda _, p=pm: self._pick_file(p))
self._install_btns[pm["plugin_id"]] = inst_btn
row.addWidget(inst_btn)
return row
# ── Install paths ─────────────────────────────────────────────────────
def _pick_file(self, pm: dict):
path, _ = QFileDialog.getOpenFileName(
self, f"Install {pm.get('name', pm['plugin_id'])}", "",
"Plugin Archives (*.zip)"
)
if path:
self._finish_install(pm, path, cleanup=False)
def _download(self, pm: dict):
import urllib.request
url = pm.get("source_url", "")
if not url:
return
pid = pm["plugin_id"]
self._set_row_busy(pid, True, "Downloading…")
tmp_path = None
try:
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".zip")
os.close(tmp_fd)
status_lbl = self._status_lbls[pid]
def _progress(block_count, block_size, total):
if total > 0:
pct = min(100, block_count * block_size * 100 // total)
status_lbl.setText(f"Downloading… {pct}%")
QApplication.processEvents()
urllib.request.urlretrieve(url, tmp_path, reporthook=_progress)
except Exception as exc:
self._set_row_busy(pid, False, "Download failed")
QMessageBox.critical(self, "Download Failed", str(exc))
if tmp_path:
_silent_remove(tmp_path)
return
self._status_lbls[pid].setText("Installing…")
QApplication.processEvents()
self._finish_install(pm, tmp_path, cleanup=True)
def _finish_install(self, pm: dict, zip_path: str, cleanup: bool):
pid = pm["plugin_id"]
try:
manifest = self._plugin_mgr.install_from_zip(zip_path)
except Exception as exc:
self._set_row_busy(pid, False,
"Deps missing" if pm.get("kind") == "deps" else "Not installed")
QMessageBox.critical(self, "Install Failed", str(exc))
return
finally:
if cleanup:
_silent_remove(zip_path)
if manifest.plugin_id != pid:
self._set_row_busy(pid, False,
"Deps missing" if pm.get("kind") == "deps" else "Not installed")
QMessageBox.warning(
self, "Wrong Plugin",
f"Expected '{pid}' but the zip contains '{manifest.plugin_id}'."
)
return
remaining = self._plugin_mgr.get_missing_dependencies(pid)
if remaining:
self._set_row_busy(pid, False, "Deps still missing")
QMessageBox.warning(
self, "Dependencies Still Missing",
"Plugin installed but these packages are still absent:\n\n"
+ "\n".join(f" {r}" for r in remaining)
+ "\n\nRe-zip the plugin with a vendor/ folder containing its dependencies."
)
return
self._status_lbls[pid].setText("✓ Ready")
self._set_row_busy(pid, False, None) # None = keep status as-is
if pid in self._install_btns:
self._install_btns[pid].setEnabled(False)
if pid in self._download_btns:
self._download_btns[pid].setEnabled(False)
self._refresh_continue_btn()
# ── Helpers ───────────────────────────────────────────────────────────
def _set_row_busy(self, plugin_id: str, busy: bool, status_text: str | None):
if status_text is not None:
self._status_lbls[plugin_id].setText(status_text)
for d in (self._install_btns, self._download_btns):
btn = d.get(plugin_id)
if btn:
btn.setEnabled(not busy)
QApplication.processEvents()
def _refresh_continue_btn(self):
installed_ids = {m.plugin_id for m in self._plugin_mgr.get_manifests()}
all_resolved = all(
pid in installed_ids
and not self._plugin_mgr.get_missing_dependencies(pid)
for pid in (pm["plugin_id"] for pm in self._missing)
)
self._continue_btn.setText("Continue ✓" if all_resolved else "Continue")
# ── Utilities ──────────────────────────────────────────────────────────────────
def _hline() -> QFrame:
f = QFrame(); f.setFrameShape(QFrame.Shape.HLine)
return f
def _silent_remove(path: str):
try:
os.unlink(path)
except Exception:
pass