diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index d495463a..67d4cebf 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -1850,58 +1850,72 @@ class PluginStoreManager: return cached[1] try: - sha_result = subprocess.run( - ['git', '-C', str(plugin_path), 'rev-parse', 'HEAD'], + # .git may be a file (worktree / submodule) containing "gitdir: ". + # Resolve it to the actual git directory before reading any files. + try: + if git_dir.is_file(): + pointer = git_dir.read_text(encoding='utf-8', errors='replace').strip() + if pointer.startswith('gitdir:'): + resolved = (plugin_path / pointer[len('gitdir:'):].strip()).resolve() + if resolved.is_dir(): + git_dir = resolved + else: + return None + else: + return None + except (OSError, NotADirectoryError): + return None + + # Read branch directly from .git/HEAD (no subprocess). + branch = '' + try: + head_text = (git_dir / 'HEAD').read_text(encoding='utf-8', errors='replace').strip() + if head_text.startswith('ref: refs/heads/'): + branch = head_text[len('ref: refs/heads/'):] + elif head_text.startswith('ref: '): + branch = head_text[len('ref: '):] + # else: detached HEAD — branch stays '' + except (OSError, NotADirectoryError): + pass + + # Remote URL from .git/config — parse [remote "origin"] url line. + remote_url = None + try: + config_text = (git_dir / 'config').read_text(encoding='utf-8', errors='replace') + in_origin = False + for line in config_text.splitlines(): + stripped = line.strip() + if stripped == '[remote "origin"]': + in_origin = True + elif stripped.startswith('['): + in_origin = False + elif in_origin and stripped.startswith('url') and '=' in stripped: + remote_url = stripped.split('=', 1)[1].strip() + break + except (OSError, NotADirectoryError): + pass + + # Single subprocess: SHA + commit date in one call. + log_result = subprocess.run( + ['git', '-C', str(plugin_path), 'log', '-1', '--format=%H%n%cI', 'HEAD'], capture_output=True, text=True, timeout=10, check=True ) - sha = sha_result.stdout.strip() - - branch_result = subprocess.run( - ['git', '-C', str(plugin_path), 'rev-parse', '--abbrev-ref', 'HEAD'], - capture_output=True, - text=True, - timeout=10, - check=True - ) - branch = branch_result.stdout.strip() - - if branch == 'HEAD': - branch = '' - - # Get remote URL - remote_url_result = subprocess.run( - ['git', '-C', str(plugin_path), 'config', '--get', 'remote.origin.url'], - capture_output=True, - text=True, - timeout=10, - check=False - ) - remote_url = remote_url_result.stdout.strip() if remote_url_result.returncode == 0 else None - - # Get commit date in ISO format - date_result = subprocess.run( - ['git', '-C', str(plugin_path), 'log', '-1', '--format=%cI', 'HEAD'], - capture_output=True, - text=True, - timeout=10, - check=True - ) - commit_date_iso = date_result.stdout.strip() + lines = log_result.stdout.strip().splitlines() + sha = lines[0] if lines else '' + commit_date_iso = lines[1] if len(lines) > 1 else '' result = { 'sha': sha, 'short_sha': sha[:7] if sha else '', - 'branch': branch + 'branch': branch, } - - # Add remote URL if available + if remote_url: result['remote_url'] = remote_url - # Add commit date if available if commit_date_iso: result['date_iso'] = commit_date_iso result['date'] = self._iso_to_date(commit_date_iso) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 1d0fedfd..73181fb0 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -1772,61 +1772,57 @@ def get_installed_plugins(): # Load config once before the loop (not per-plugin) full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} - # Format for the web interface - plugins = [] - for plugin_info in all_plugin_info: + def _build_plugin_entry(plugin_info): plugin_id = plugin_info.get('id') + try: + return _build_plugin_entry_inner(plugin_info, plugin_id) + except Exception: + logger.exception("Error building plugin entry for %s — skipping", plugin_id) + return None + def _build_plugin_entry_inner(plugin_info, plugin_id): # Re-read manifest from disk to ensure we have the latest metadata manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json" if manifest_path.exists(): try: with open(manifest_path, 'r', encoding='utf-8') as f: fresh_manifest = json.load(f) - # Update plugin_info with fresh manifest data - plugin_info.update(fresh_manifest) - except Exception as e: - # If we can't read the fresh manifest, use the cached one - print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}") + if isinstance(fresh_manifest, dict): + plugin_info.update(fresh_manifest) + else: + logger.debug("Manifest for %s is not a dict (%s) — skipping merge", + plugin_id, type(fresh_manifest).__name__) + except (FileNotFoundError, PermissionError, json.JSONDecodeError) as e: + logger.debug("Could not read fresh manifest for %s: %s", plugin_id, e) - # Get enabled status from config (source of truth) - # Read from config file first, fall back to plugin instance if config doesn't have the key + # Enabled status: config is source of truth, fall back to instance enabled = None - if api_v3.config_manager: - plugin_config = full_config.get(plugin_id, {}) - # Check if 'enabled' key exists in config (even if False) - if 'enabled' in plugin_config: - enabled = bool(plugin_config['enabled']) + plugin_config = full_config.get(plugin_id, {}) + if 'enabled' in plugin_config: + enabled = bool(plugin_config['enabled']) - # Fallback to plugin instance if config doesn't have enabled key + # Single get_plugin() call shared for both enabled fallback and Vegas mode + plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) if enabled is None: - plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) - if plugin_instance: - enabled = plugin_instance.enabled - else: - # Default to True if no config key and plugin not loaded (matches BasePlugin default) - enabled = True + enabled = plugin_instance.enabled if plugin_instance else True - # Get verified status from store registry (no GitHub API calls needed) + # Verified from registry (no network call) store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id) verified = store_info.get('verified', False) if store_info else False - # Get local git info for installed plugin (actual installed commit) + # Local git info (single subprocess on cache miss, zero on hit) plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id local_git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path) if plugin_path.exists() else None - # Use local git info if available (actual installed commit), otherwise fall back to manifest/store info if local_git_info: - last_commit = local_git_info.get('short_sha') or local_git_info.get('sha', '')[:7] if local_git_info.get('sha') else None + sha = local_git_info.get('sha', '') + last_commit = local_git_info.get('short_sha') or (sha[:7] if sha else None) branch = local_git_info.get('branch') - # Use commit date from git if available last_updated = local_git_info.get('date_iso') or local_git_info.get('date') else: - # Fall back to manifest/store info if no local git info last_updated = plugin_info.get('last_updated') last_commit = plugin_info.get('last_commit') or plugin_info.get('last_commit_sha') branch = plugin_info.get('branch') - if store_info: last_updated = last_updated or store_info.get('last_updated') or store_info.get('last_updated_iso') last_commit = last_commit or store_info.get('last_commit') or store_info.get('last_commit_sha') @@ -1836,35 +1832,26 @@ def get_installed_plugins(): if store_info and not last_commit_message: last_commit_message = store_info.get('last_commit_message') - # Get web_ui_actions from manifest if available - web_ui_actions = plugin_info.get('web_ui_actions', []) - - # Get Vegas display mode info from plugin instance + # Vegas mode from instance, overridden by explicit config value vegas_mode = None vegas_content_type = None - plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) if plugin_instance: try: - # Try to get the display mode enum if hasattr(plugin_instance, 'get_vegas_display_mode'): mode = plugin_instance.get_vegas_display_mode() vegas_mode = mode.value if hasattr(mode, 'value') else str(mode) except (AttributeError, TypeError, ValueError) as e: logger.debug("[%s] Failed to get vegas_display_mode: %s", plugin_id, e) try: - # Get legacy content type as fallback if hasattr(plugin_instance, 'get_vegas_content_type'): vegas_content_type = plugin_instance.get_vegas_content_type() except (AttributeError, TypeError, ValueError) as e: logger.debug("[%s] Failed to get vegas_content_type: %s", plugin_id, e) - # Also check plugin config for explicit vegas_mode setting - if api_v3.config_manager: - plugin_cfg = full_config.get(plugin_id, {}) - if 'vegas_mode' in plugin_cfg: - vegas_mode = plugin_cfg['vegas_mode'] + if 'vegas_mode' in plugin_config: + vegas_mode = plugin_config['vegas_mode'] - plugins.append({ + return { 'id': plugin_id, 'name': plugin_info.get('name', plugin_id), 'version': plugin_info.get('version', ''), @@ -1879,10 +1866,15 @@ def get_installed_plugins(): 'last_commit': last_commit, 'last_commit_message': last_commit_message, 'branch': branch, - 'web_ui_actions': web_ui_actions, + 'web_ui_actions': plugin_info.get('web_ui_actions', []), 'vegas_mode': vegas_mode, - 'vegas_content_type': vegas_content_type - }) + 'vegas_content_type': vegas_content_type, + } + + from concurrent.futures import ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=8) as executor: + results = list(executor.map(_build_plugin_entry, all_plugin_info)) + plugins = [r for r in results if r is not None] return jsonify({'status': 'success', 'data': {'plugins': plugins}}) except Exception as e: diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 19d25253..429d2b21 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -1223,13 +1223,21 @@ function initializePlugins() { // during a re-swap, fetch fresh data including GitHub commit/version info. const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired(); window.pluginManager._reswap = false; - // Await the installed-plugins fetch so window.installedPlugins is populated before - // searchPluginStore renders Installed/Reinstall badges against it. - loadInstalledPlugins().catch(err => { - console.error('[PluginStore] loadInstalledPlugins failed:', err); - }).finally(() => { - searchPluginStore(!isReswapWarm); - }); + + // Fire both requests in parallel so the store doesn't wait for installed plugins. + // The store renders install/update badges using window.installedPlugins || [] so + // it works with an empty list. When installed plugins finish loading we do a + // lightweight re-render from the already-cached store data to refresh the badges. + searchPluginStore(!isReswapWarm); + loadInstalledPlugins() + .catch(err => console.error('[PluginStore] loadInstalledPlugins failed:', err)) + .then(() => { + // Re-render store from cache to update install/update/reinstall badges now + // that window.installedPlugins is populated. No network call — instant. + if (typeof applyStoreFiltersAndSort === 'function') { + applyStoreFiltersAndSort(true); + } + }); // Setup search functionality (with guard against duplicate listeners) const searchInput = document.getElementById('plugin-search'); @@ -1418,6 +1426,9 @@ function renderInstalledPlugins(plugins) { } } + // Remove skeleton cards before rendering real content + container.querySelectorAll('.installed-skeleton').forEach(el => el.remove()); + if (plugins.length === 0) { container.innerHTML = `
diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index 3b01bd74..cf6aa726 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -31,7 +31,52 @@
- + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
@@ -203,14 +248,30 @@
- +
-
-
-
-
-
-
+
+ {% for _ in range(10) %} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endfor %}