diff --git a/main.py b/main.py index f736ad1..86845a0 100644 --- a/main.py +++ b/main.py @@ -34,6 +34,7 @@ from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIc from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen from file_ext_registration import register_file_association, ELEVATION_FLAG +from plugin_manager import PluginManager from project_manager import ProjectManager from src.window.about import AboutWindow from src.window.plugins import PluginsWindow @@ -579,6 +580,7 @@ class MainApplication(QMainWindow): self.files_are_dirty = False self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path) + self.plugin_manager = PluginManager(self) # Initialization to ensure that saving can occur for item in DATA_SCHEMA: @@ -620,7 +622,9 @@ class MainApplication(QMainWindow): self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found) self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update) self.local_check_thread.start() - + + self.plugin_manager.reload_plugins() + self.show() # Check if we should pop up the welcome screen @@ -1327,7 +1331,7 @@ class MainApplication(QMainWindow): def plugins_gui(self): if self.plugins is None or not self.plugins.isVisible(): - self.plugins = PluginsWindow(self) + self.plugins = PluginsWindow(self, self.plugin_manager) self.plugins.show() def terminal_gui(self): diff --git a/plugin_manager.py b/plugin_manager.py new file mode 100644 index 0000000..2c43948 --- /dev/null +++ b/plugin_manager.py @@ -0,0 +1,320 @@ +""" +Filename: plugin_manager.py +Description: Manager file for anything plugin related + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + +# Built-in imports +import io +import sys +import json +import shutil +import zipfile +import platform +import urllib.request +import importlib.util +from pathlib import Path +from typing import Any, cast + +# External library imports +from PySide6.QtCore import QObject, QUrl, Signal +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox + +from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME + + +def get_current_platform_id() -> str: + """Returns standardized platform identifier (win_x64, darwin_arm64, etc.).""" + sys_name = sys.platform + arch = platform.machine().lower() + + if sys_name == "win32": + return "win_x64" if "64" in arch or "amd64" in arch else "win_x86" + elif sys_name == "darwin": + return "darwin_arm64" if "arm" in arch or "aarch64" in arch else "darwin_x64" + elif sys_name.startswith("linux"): + return "linux_x64" if "64" in arch else "linux_x86" + return sys_name + + +def parse_version(v_str: str) -> tuple[int, ...]: + """Parses a version string into a comparable integer tuple.""" + try: + return tuple(int(x) for x in v_str.strip().lstrip("v").split(".")) + except ValueError: + return (0, 0, 0) + + +class PluginManager(QObject): + """ + Handles plugin discovery, dynamic loading, menu building, + and plugin lifecycle (install, toggle, uninstall). + """ + + plugins_changed = Signal() + + def __init__(self, main_window: QMainWindow) -> None: + super().__init__() + self.main_window: QMainWindow = main_window + self.plugins_dir: Path = self._resolve_plugins_dir() + self.loaded_plugins: list[Any] = [] + self.current_platform: str = get_current_platform_id() + self.current_app_version: tuple[int, ...] = parse_version(str(CURRENT_VERSION)) + + def _resolve_plugins_dir(self) -> Path: + """Determines the local plugins directory based on execution context.""" + if PLATFORM_NAME == "darwin": + base_dir = Path(sys.executable).parent / "../../.." + else: + base_dir = Path.cwd() + + plugins_path = (base_dir / "plugins").resolve() + plugins_path.mkdir(parents=True, exist_ok=True) + return plugins_path + + + def reload_plugins(self) -> list[Any]: + """Scans directory, imports enabled plugins, and updates the main application menu.""" + self.unload_plugins() + + plugins_dir_str = str(self.plugins_dir) + if plugins_dir_str not in sys.path: + sys.path.insert(0, plugins_dir_str) + + failed_plugins: list[tuple[str, str]] = [] + + for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()): + if entry.name.startswith((".", "__")) or entry.name.endswith(".disabled"): + continue + + module_name = "" + init_file: Path | None = None + + if entry.is_dir(): + init_file = entry / "__init__.py" + module_name = entry.name + elif entry.is_file() and entry.suffix == ".py": + init_file = entry + module_name = entry.stem + + if not init_file or not init_file.exists(): + continue + + try: + instance = self._import_and_instantiate(module_name, init_file) + if instance is not None: + self.loaded_plugins.append(instance) + except Exception as e: + failed_plugins.append((module_name, str(e))) + + # Report failures after all plugins have attempted to load + if failed_plugins: + error_details = "\n".join(f"• {name}: {err}" for name, err in failed_plugins) + summary_msg = f"One or more plugins failed to load:\n\n{error_details}" + + print(f"[PluginManager] {summary_msg}") + + parent_widget = getattr(self, "main_window", None) or self + QMessageBox.warning( + parent_widget, + "Plugin Load Failures", + summary_msg + ) + + self.build_plugins_menu() + self.plugins_changed.emit() + return self.loaded_plugins + + def _import_and_instantiate(self, module_name: str, file_path: Path) -> Any | None: + """Dynamically imports a plugin module, attaches its metadata, and returns the Plugin instance.""" + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + raise ImportError(f"Invalid or missing spec for file: {file_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + if hasattr(module, "Plugin") and isinstance(module.Plugin, type): + instance = module.Plugin(self.main_window) + + # Attach manifest metadata directly to the plugin instance if plugin.json exists + manifest_path = file_path.parent / "plugin.json" + if manifest_path.exists(): + try: + with open(manifest_path, "r", encoding="utf-8") as f: + instance.metadata = json.load(f) + # Override class name property if specified in manifest + if "name" in instance.metadata: + instance.name = instance.metadata["name"] + except Exception as e: + print(f"[PluginManager] Could not attach metadata to '{module_name}': {e}") + + return instance + + return None + + def build_plugins_menu(self) -> None: + """Constructs or refreshes submenus under the top-level 'Plugins' menu item.""" + menubar = self.main_window.menuBar() + + # Dynamically locate the existing 'Plugins' menu on the menubar + plugins_menu: QMenu | None = None + for action in menubar.actions(): + clean_text = action.text().replace("&", "").strip().lower() + if clean_text == "plugins": + plugins_menu = action.menu() + break + + # If it doesn't exist yet, create it + if plugins_menu is None: + plugins_menu = menubar.addMenu("&Plugins") + + plugins_menu.clear() + + # 1. Populate loaded plugin submenus + if not self.loaded_plugins: + disabled_action = plugins_menu.addAction("No active plugins") + disabled_action.setEnabled(False) + else: + for plugin in self.loaded_plugins: + plugin_name = getattr(plugin, "name", "Unnamed Plugin") + plugin_submenu = plugins_menu.addMenu(plugin_name) + + if hasattr(plugin, "register_menu") and callable(plugin.register_menu): + plugin.register_menu(plugin_submenu) + + # 2. Append entry to open Plugin Manager GUI + plugins_menu.addSeparator() + manager_action = plugins_menu.addAction("Manage Plugins...") + + if hasattr(self.main_window, "plugins_gui") and callable(self.main_window.plugins_gui): + manager_action.triggered.connect(self.main_window.plugins_gui) + + def unload_plugins(self) -> None: + """Clears current active plugin instances.""" + self.loaded_plugins.clear() + + def get_installed_plugins_info(self) -> list[dict[str, Any]]: + """Returns metadata for all local plugins in the plugins folder by reading their plugin.json.""" + results: list[dict[str, Any]] = [] + + for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()): + if entry.name.startswith((".", "__")): + continue + + is_disabled = entry.name.endswith(".disabled") + clean_name = entry.name[:-9] if is_disabled else entry.name + + if entry.is_dir() or (entry.is_file() and (entry.suffix == ".py" or entry.name.endswith(".py.disabled"))): + if entry.is_file() and entry.stem in ("__init__", "__init__.py"): + continue + + # Load local plugin.json if present + manifest: dict[str, Any] = {} + manifest_path = (entry / "plugin.json") if entry.is_dir() else None + + if manifest_path and manifest_path.exists(): + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + except Exception as e: + print(f"[PluginManager] Warning: Failed to read manifest for '{entry.name}': {e}") + + display_name = manifest.get("name", clean_name.removesuffix(".py")) + version = manifest.get("version", "1.0.0") + description = manifest.get("description", "") + author = manifest.get("author", "Unknown") + + results.append({ + "id": manifest.get("id", clean_name), + "name": display_name, + "version": version, + "description": description, + "author": author, + "path": entry, + "is_disabled": is_disabled, + "manifest": manifest, + }) + + return results + + def toggle_plugin_state(self, plugin_path: Path) -> None: + """Swaps a plugin between enabled and disabled by renaming with/without '.disabled'.""" + if not plugin_path.exists(): + return + + if plugin_path.name.endswith(".disabled"): + new_path = plugin_path.with_name(plugin_path.name.removesuffix(".disabled")) + else: + new_path = plugin_path.with_name(f"{plugin_path.name}.disabled") + + plugin_path.rename(new_path) + self.reload_plugins() + + def uninstall_plugin(self, plugin_path: Path) -> None: + """Deletes a plugin directory or file from disk.""" + if not plugin_path.exists(): + return + + if plugin_path.is_dir(): + shutil.rmtree(plugin_path) + else: + plugin_path.unlink() + + self.reload_plugins() + + def fetch_remote_repositories(self, repo_urls: list[str]) -> tuple[list[dict[str, Any]], bool]: + """Fetches remote plugin metadata from repository URLs.""" + aggregated: list[dict[str, Any]] = [] + failed_count = 0 + + for url in repo_urls: + try: + req = urllib.request.Request( + url, + headers={"User-Agent": f"{APP_NAME}-PluginManager"}, + ) + with urllib.request.urlopen(req, timeout=5) as response: + if response.status == 200: + raw_data = response.read().decode("utf-8") + data = json.loads(raw_data) + if isinstance(data, list): + aggregated.extend(cast(list[Any], data)) + else: + failed_count += 1 + except Exception: + failed_count += 1 + + all_failed = failed_count == len(repo_urls) and len(repo_urls) > 0 + return aggregated, all_failed + + def install_plugin_from_url(self, download_url: str, plugin_id: str = "") -> Path: + """ + Downloads a single-folder ZIP archive and extracts it directly into plugins/. + """ + req = urllib.request.Request( + download_url, + headers={"User-Agent": f"{APP_NAME}-PluginManager"}, + ) + + with urllib.request.urlopen(req, timeout=15) as response: + if response.status != 200: + raise RuntimeError(f"Download failed with HTTP status code {response.status}") + + zip_bytes = response.read() + + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref: + zip_ref.extractall(self.plugins_dir) + + self.reload_plugins() + return self.plugins_dir + + def open_plugins_directory(self) -> bool: + """Opens the local plugins folder in the operating system's file manager.""" + if self.plugins_dir.exists(): + return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir))) + return False \ No newline at end of file diff --git a/src/window/plugins.py b/src/window/plugins.py index d4b96ad..fa496cd 100644 --- a/src/window/plugins.py +++ b/src/window/plugins.py @@ -1,54 +1,52 @@ """ Filename: plugins.py Description: Plugins window -Note: Compliant with pylance strict type checking Author: Tyler de Zeeuw License: GPL-3.0 """ # Built-in imports -import json -import sys -import urllib.request -from pathlib import Path -from typing import Any, cast +from typing import Any # External library imports from PySide6.QtCore import Qt -from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QTabWidget, QVBoxLayout, QWidget +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, + QMessageBox, QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget +) -from src.shared.shareddata import APP_NAME, PLATFORM_NAME, PLUGINS_URL +from plugin_manager import PluginManager, parse_version +from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL class PluginsWindow(QWidget): - """ - Plugins window containing two tabs: Installed Plugins and Plugin Browser. - Args: - parent (QWidget | None, optional): Parent widget of this window. Defaults to None. - """ - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent, Qt.WindowType.Window) + def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None: + super().__init__(None, Qt.WindowType.Window) + + self.main_app_window = parent + self.manager: PluginManager = plugin_manager + self.setWindowTitle(f"{APP_NAME.upper()} - Plugins") - self.resize(650, 500) + self.resize(750, 500) - self.plugins_dir: Path = self._resolve_plugins_dir() self.repository_urls: list[str] = [PLUGINS_URL] self.remote_plugins_data: list[dict[str, Any]] = [] self._has_fetched_remote: bool = False - main_layout = QVBoxLayout(self) + # Refresh UI automatically if manager updates state + self.manager.plugins_changed.connect(self.refresh_installed_plugins) + main_layout = QVBoxLayout(self) self.tab_widget = QTabWidget(self) + self.installed_tab = self._create_installed_tab() self.browser_tab = self._create_browser_tab() self.tab_widget.addTab(self.installed_tab, "Installed Plugins") self.tab_widget.addTab(self.browser_tab, "Plugin Browser") - - self.tab_widget.setCurrentIndex(0) self.tab_widget.currentChanged.connect(self._on_tab_changed) main_layout.addWidget(self.tab_widget) @@ -56,169 +54,257 @@ class PluginsWindow(QWidget): self.refresh_installed_plugins() - def _resolve_plugins_dir(self) -> Path: - """Determines the local plugins directory based on execution context.""" - if PLATFORM_NAME == "darwin": - base_dir = Path(sys.executable).parent / "../../.." - else: - base_dir = Path.cwd() + def refresh_installed_plugins(self) -> None: + """Refreshes installed list using metadata from PluginManager.""" + self.installed_list.clear() + plugins_info = self.manager.get_installed_plugins_info() - return (base_dir / "plugins").resolve() + for info in plugins_info: + display_text = f"{info['name']} (v{info.get('version', '1.0.0')})" + if info["is_disabled"]: + display_text += " [Disabled]" + + item = QListWidgetItem(display_text, self.installed_list) + item.setData(Qt.ItemDataRole.UserRole, info) + + if info["is_disabled"]: + item.setForeground(QColor("#757575")) + + if self.installed_list.count() == 0: + self.installed_list.addItem("No plugins installed.") + self._clear_details_panel() + + def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None: + """Updates the right-hand details panel when a plugin is selected.""" + if not current: + self._clear_details_panel() + return + + info = current.data(Qt.ItemDataRole.UserRole) + if not isinstance(info, dict): + self._clear_details_panel() + return + + name = info.get("name", "Unknown") + version = info.get("version", "1.0.0") + author = info.get("author", "Unknown") + desc = info.get("description", "No description provided.") + is_disabled = info.get("is_disabled", False) + path = info.get("path", "") + + self.lbl_plugin_title.setText(name) + self.lbl_plugin_meta.setText(f"Version: {version}  |  Author: {author}") + + if is_disabled: + self.lbl_plugin_status.setText("Status: Disabled") + else: + self.lbl_plugin_status.setText("Status: Active") + + self.txt_plugin_desc.setHtml(desc) + self.lbl_plugin_path.setText(f"Path: {path}") + + def _clear_details_panel(self) -> None: + """Clears details display when nothing is selected.""" + self.lbl_plugin_title.setText("Select a plugin") + self.lbl_plugin_meta.setText("") + self.lbl_plugin_status.setText("") + self.txt_plugin_desc.clear() + self.lbl_plugin_path.setText("") + + def toggle_enable_disable(self) -> None: + selected = self.installed_list.currentItem() + if not selected: + return + info = selected.data(Qt.ItemDataRole.UserRole) + if isinstance(info, dict) and "path" in info: + self.manager.toggle_plugin_state(info["path"]) + + def uninstall_plugin(self) -> None: + selected = self.installed_list.currentItem() + if not selected: + return + info = selected.data(Qt.ItemDataRole.UserRole) + if not isinstance(info, dict) or "path" not in info: + return + + reply = QMessageBox.question( + self, "Confirm Uninstall", + f"Are you sure you want to delete '{info['name']}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self.manager.uninstall_plugin(info["path"]) + + def fetch_remote_plugins(self) -> None: + self.browser_list.clear() + self.browser_list.addItem("Fetching remote repositories...") + self.btn_install.setEnabled(False) + + plugins, all_failed = self.manager.fetch_remote_repositories(self.repository_urls) + self.remote_plugins_data = plugins + self._has_fetched_remote = True + + if plugins: + self._populate_browser_list() + elif all_failed: + self.browser_list.clear() + self.browser_list.addItem("Unable to load plugins from configured repositories.") + else: + self.browser_list.clear() + self.browser_list.addItem("No plugins found across configured repositories.") + + def _populate_browser_list(self) -> None: + self.browser_list.clear() + for plugin in self.remote_plugins_data: + name = plugin.get("name", "Unknown") + version = plugin.get("version", "v0.0") + desc = plugin.get("description", "") + platforms = plugin.get("platforms", []) + min_v_str = plugin.get("min_app_version", "0.0.0") + + is_platform_ok = not platforms or self.manager.current_platform in platforms + is_version_ok = self.manager.current_app_version >= parse_version(min_v_str) + is_compatible = is_platform_ok and is_version_ok + + display_text = f"{name} (v{version}) - {desc}" + if not is_compatible: + display_text += " [Incompatible]" + + item = QListWidgetItem(display_text, self.browser_list) + item.setData(Qt.ItemDataRole.UserRole, plugin) + item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible) + + if not is_compatible: + item.setForeground(QColor("#d32f2f")) + + def install_selected_plugin(self) -> None: + selected = self.browser_list.currentItem() + if not selected: + return + data = selected.data(Qt.ItemDataRole.UserRole) + if not data: + return + + try: + target_path = self.manager.install_plugin_from_url( + data.get("download_url", ""), + data.get("id", "unnamed") + ) + QMessageBox.information(self, "Success", f"Installed plugin to:\n{target_path}") + except Exception as e: + QMessageBox.critical(self, "Installation Failed", f"Could not install plugin:\n{e}") def _on_tab_changed(self, index: int) -> None: - """Triggers remote fetch only when switching specifically to the Plugin Browser tab.""" if index == 1 and not self._has_fetched_remote: self.fetch_remote_plugins() - def refresh_installed_plugins(self) -> None: - """Scans local plugins directory silently without UI popups.""" - self.installed_list.clear() - self.plugins_dir.mkdir(parents=True, exist_ok=True) - - installed_plugins: list[str] = [] - - for entry in self.plugins_dir.iterdir(): - if entry.is_dir() and not entry.name.startswith((".", "__")): - installed_plugins.append(entry.name) - elif entry.is_file() and entry.suffix == ".py" and entry.stem != "__init__": - installed_plugins.append(entry.stem) - - if installed_plugins: - self.installed_list.addItems(sorted(installed_plugins)) - else: - self.installed_list.addItem("No plugins installed.") - - def add_custom_repository(self) -> None: - """Adds a custom repository URL from the input field and refreshes the browser list.""" - url = self.repo_input.text().strip() - if not url: - return - - if not (url.startswith("http://") or url.startswith("https://")): - self._show_browser_error("Invalid URL format. Must start with http:// or https://") - return - - if url not in self.repository_urls: - self.repository_urls.append(url) - - self.repo_input.clear() - self.fetch_remote_plugins() - - def fetch_remote_plugins(self) -> None: - """ - Fetches plugins.json from all configured repository URLs directly into memory. - Aggregates results and handles network failures gracefully in-line. - """ - self.browser_list.clear() - self.browser_list.addItem("Fetching remote repositories...") - - aggregated_plugins: list[dict[str, Any]] = [] - failed_count: int = 0 - - for url in self.repository_urls: - try: - req = urllib.request.Request( - url, - headers={"User-Agent": f"{APP_NAME}-PluginManager"}, - ) - with urllib.request.urlopen(req, timeout=5) as response: - if response.status == 200: - raw_data = response.read().decode("utf-8") - plugins = json.loads(raw_data) - if isinstance(plugins, list): - aggregated_plugins.extend(cast(list[Any], plugins)) - else: - failed_count += 1 - except Exception: - failed_count += 1 - - self.remote_plugins_data = aggregated_plugins - self._has_fetched_remote = True - - if aggregated_plugins: - self._populate_browser_list() - elif failed_count == len(self.repository_urls): - self._show_browser_error( - "Unable to load plugins from any repository. Check your connection or URLs." - ) - else: - self._show_browser_error("No plugins found across configured repositories.") - - def _show_browser_error(self, message: str) -> None: - """Renders error text directly in the list widget.""" - self.browser_list.clear() - self.browser_list.addItem(message) - - def _populate_browser_list(self) -> None: - """Populates list widget with parsed remote plugin data.""" - self.browser_list.clear() - - for plugin in self.remote_plugins_data: - name = plugin.get("name", "Unknown Plugin") - version = plugin.get("version", "v0.0") - desc = plugin.get("description", "No description provided.") - - display_text = f"{name} ({version}) - {desc}" - item = QListWidgetItem(display_text, self.browser_list) - item.setData(Qt.ItemDataRole.UserRole, plugin) + def _on_browser_item_changed(self, current: QListWidgetItem | None, _: Any) -> None: + if current: + self.btn_install.setEnabled(bool(current.data(Qt.ItemDataRole.UserRole + 1))) def _create_installed_tab(self) -> QWidget: - """Creates 'Installed Plugins' tab UI.""" tab = QWidget() layout = QVBoxLayout(tab) - path_label = QLabel(f"Directory: {self.plugins_dir}", tab) + # Header bar with directory path and Open Folder button + path_layout = QHBoxLayout() + path_label = QLabel(f"Plugins Directory: {self.manager.plugins_dir}", tab) + btn_open_folder = QPushButton("Open Folder", tab) + btn_open_folder.setToolTip("Open this directory in Explorer / Finder") + btn_open_folder.clicked.connect(self.manager.open_plugins_directory) + + path_layout.addWidget(path_label) + path_layout.addStretch() + path_layout.addWidget(btn_open_folder) + + # Splitter Layout: Left side List, Right side Detail Panel + content_layout = QHBoxLayout() + self.installed_list = QListWidget(tab) + self.installed_list.currentItemChanged.connect(self._on_installed_item_changed) - button_layout = QHBoxLayout() - self.btn_refresh_local = QPushButton("Refresh List", tab) - self.btn_enable_disable = QPushButton("Enable/Disable", tab) - self.btn_uninstall = QPushButton("Uninstall", tab) + # Right-side Details Panel + self.details_panel = QWidget(tab) + details_layout = QVBoxLayout(self.details_panel) + details_layout.setContentsMargins(10, 0, 0, 0) - self.btn_refresh_local.clicked.connect(self.refresh_installed_plugins) + self.lbl_plugin_title = QLabel("Select a plugin", self.details_panel) + self.lbl_plugin_title.setStyleSheet("font-size: 13pt; font-weight: bold;") - button_layout.addWidget(self.btn_refresh_local) - button_layout.addStretch() - button_layout.addWidget(self.btn_enable_disable) - button_layout.addWidget(self.btn_uninstall) + self.lbl_plugin_meta = QLabel("", self.details_panel) + self.lbl_plugin_status = QLabel("", self.details_panel) - layout.addWidget(path_label) - layout.addWidget(self.installed_list) - layout.addLayout(button_layout) + self.txt_plugin_desc = QTextBrowser(self.details_panel) + self.txt_plugin_desc.setPlaceholderText("Select an installed plugin to view details...") + self.lbl_plugin_path = QLabel("", self.details_panel) + self.lbl_plugin_path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.lbl_plugin_path.setWordWrap(True) + + details_layout.addWidget(self.lbl_plugin_title) + details_layout.addWidget(self.lbl_plugin_meta) + details_layout.addWidget(self.lbl_plugin_status) + details_layout.addWidget(QLabel("Description:", self.details_panel)) + details_layout.addWidget(self.txt_plugin_desc) + details_layout.addWidget(self.lbl_plugin_path) + + content_layout.addWidget(self.installed_list, stretch=1) + content_layout.addWidget(self.details_panel, stretch=1) + + # Bottom Action Buttons + btn_layout = QHBoxLayout() + btn_refresh = QPushButton("Refresh List", tab) + btn_enable = QPushButton("Enable / Disable", tab) + btn_uninstall = QPushButton("Uninstall", tab) + + btn_refresh.clicked.connect(self.refresh_installed_plugins) + btn_enable.clicked.connect(self.toggle_enable_disable) + btn_uninstall.clicked.connect(self.uninstall_plugin) + + btn_layout.addWidget(btn_refresh) + btn_layout.addStretch() + btn_layout.addWidget(btn_enable) + btn_layout.addWidget(btn_uninstall) + + layout.addLayout(path_layout) + layout.addLayout(content_layout) + layout.addLayout(btn_layout) return tab def _create_browser_tab(self) -> QWidget: - """Creates 'Plugin Browser' tab UI.""" tab = QWidget() layout = QVBoxLayout(tab) - # Custom Repository Input Controls + info = QLabel( + f"Platform: {self.manager.current_platform} | " + f"App Version: v{CURRENT_VERSION}", tab + ) + repo_layout = QHBoxLayout() self.repo_input = QLineEdit(tab) self.repo_input.setPlaceholderText("Enter custom plugins.json URL...") - self.btn_add_repo = QPushButton("Add Repo", tab) - self.btn_add_repo.clicked.connect(self.add_custom_repository) + btn_add = QPushButton("Add Repo", tab) repo_layout.addWidget(self.repo_input) - repo_layout.addWidget(self.btn_add_repo) + repo_layout.addWidget(btn_add) - label = QLabel("Available Plugins from Repositories:", tab) self.browser_list = QListWidget(tab) + self.browser_list.currentItemChanged.connect(self._on_browser_item_changed) - button_layout = QHBoxLayout() - self.btn_fetch_remote = QPushButton("Fetch Remote Lists", tab) + btn_layout = QHBoxLayout() + btn_fetch = QPushButton("Fetch Remote Lists", tab) self.btn_install = QPushButton("Install Plugin", tab) + self.btn_install.setEnabled(False) - self.btn_fetch_remote.clicked.connect(self.fetch_remote_plugins) + btn_fetch.clicked.connect(self.fetch_remote_plugins) + self.btn_install.clicked.connect(self.install_selected_plugin) - button_layout.addWidget(self.btn_fetch_remote) - button_layout.addStretch() - button_layout.addWidget(self.btn_install) + btn_layout.addWidget(btn_fetch) + btn_layout.addStretch() + btn_layout.addWidget(self.btn_install) + layout.addWidget(info) layout.addLayout(repo_layout) - layout.addWidget(label) layout.addWidget(self.browser_list) - layout.addLayout(button_layout) - + layout.addLayout(btn_layout) return tab \ No newline at end of file