summaryrefslogtreecommitdiff
path: root/ui/windows/missing_plugins_dialog.py
diff options
context:
space:
mode:
Diffstat (limited to 'ui/windows/missing_plugins_dialog.py')
-rw-r--r--ui/windows/missing_plugins_dialog.py242
1 files changed, 242 insertions, 0 deletions
diff --git a/ui/windows/missing_plugins_dialog.py b/ui/windows/missing_plugins_dialog.py
new file mode 100644
index 0000000..4a183d3
--- /dev/null
+++ b/ui/windows/missing_plugins_dialog.py
@@ -0,0 +1,242 @@
+"""
+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 <b>Continue</b> 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"<b>{pm.get('name', pm['plugin_id'])}</b>"
+ f" <small>v{pm.get('version', '?')}</small>")
+ 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