import { CommonModule } from '@angular/common'; import { Component, EventEmitter, Output, computed, inject, input, signal } from '@angular/core'; import { Router } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideArrowLeft, lucideBug, lucideCheck, lucidePackage, lucidePlay, lucideRefreshCw, lucideSettings, lucideShield, lucideStore, lucideX } from '@ng-icons/lucide'; import type { PluginCapabilityId, TojuPluginInstallScope } from '../../../../shared-kernel'; import { PluginCapabilityService } from '../../application/services/plugin-capability.service'; import { PluginHostService } from '../../application/services/plugin-host.service'; import { PluginLoggerService } from '../../application/services/plugin-logger.service'; import { PluginRegistryService } from '../../application/services/plugin-registry.service'; import { PluginRequirementStateService } from '../../application/services/plugin-requirement-state.service'; import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service'; import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic'; import type { RegisteredPlugin } from '../../domain/models/plugin-runtime.models'; import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-host.component'; type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirements' | 'settings'; @Component({ selector: 'app-plugin-manager', standalone: true, imports: [ CommonModule, NgIcon, PluginRenderHostComponent ], templateUrl: './plugin-manager.component.html', viewProviders: [ provideIcons({ lucideArrowLeft, lucideBug, lucideCheck, lucidePackage, lucidePlay, lucideRefreshCw, lucideSettings, lucideShield, lucideStore, lucideX }) ] }) export class PluginManagerComponent { @Output() readonly closed = new EventEmitter(); @Output() readonly storeOpened = new EventEmitter(); readonly scope = input('client'); readonly capabilities = inject(PluginCapabilityService); readonly host = inject(PluginHostService); readonly logger = inject(PluginLoggerService); readonly registry = inject(PluginRegistryService); readonly requirementState = inject(PluginRequirementStateService); readonly router = inject(Router); readonly uiRegistry = inject(PluginUiRegistryService); readonly activeTab = signal('installed'); readonly busyPluginId = signal(null); readonly busyAll = signal(false); readonly selectedPluginId = signal(null); readonly allEntries = this.registry.entries; readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry))); readonly managerTitle = computed(() => this.scope() === 'server' ? 'Server plugins' : 'Client plugins'); readonly managerDescription = computed(() => this.scope() === 'server' ? 'Plugins installed for the current chat server.' : 'Global client plugins installed on this device.'); readonly selectedPlugin = computed(() => { const selectedPluginId = this.selectedPluginId(); return this.entries().find((entry) => entry.manifest.id === selectedPluginId) ?? this.entries()[0] ?? null; }); readonly missingCapabilities = computed(() => { const selectedPlugin = this.selectedPlugin(); return selectedPlugin ? this.capabilities.missing(selectedPlugin.manifest) : []; }); readonly selectedLogs = computed(() => { const selectedPlugin = this.selectedPlugin(); return selectedPlugin ? this.logger.entries().filter((entry) => entry.pluginId === selectedPlugin.manifest.id) .slice(-20) : []; }); readonly extensionCounts = computed(() => ({ appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, channelSections: this.uiRegistry.channelSectionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, composerActions: this.uiRegistry.composerActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, embeds: this.uiRegistry.embedRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, profileActions: this.uiRegistry.profileActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, settingsPages: this.uiRegistry.settingsPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, sidePanels: this.uiRegistry.sidePanelRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length })); readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []); readonly uiConflicts = computed(() => this.uiRegistry.conflicts() .filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId)))); readonly selectedRequirement = computed(() => { const selectedPlugin = this.selectedPlugin(); return selectedPlugin ? this.requirementState.comparisonFor(selectedPlugin.manifest.id) : null; }); readonly selectedSettingsSchema = computed(() => this.selectedPlugin()?.manifest.settings ?? null); readonly selectedSettingsPages = computed(() => { const selectedPlugin = this.selectedPlugin(); return selectedPlugin ? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id) : []; }); readonly emptyTitle = computed(() => this.scope() === 'server' ? 'No server plugins installed.' : 'No client plugins installed.'); readonly emptyBody = computed(() => this.scope() === 'server' ? 'Server-scoped plugins use scope: server in toju-plugin.json.' : 'Client-scoped plugins use scope: client or omit scope in toju-plugin.json.'); readonly selectedDocs = computed(() => { const manifest = this.selectedPlugin()?.manifest; if (!manifest) { return []; } return [ { label: 'Readme', url: manifest.readme }, { label: 'Homepage', url: manifest.homepage }, { label: 'Changelog', url: manifest.changelog }, { label: 'Support', url: manifest.bugs } ].filter((item): item is { label: string; url: string } => typeof item.url === 'string' && item.url.length > 0); }); setTab(tab: PluginManagerTab): void { this.activeTab.set(tab); } openStore(): void { const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url; this.storeOpened.emit(); void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } }); } selectPlugin(pluginId: string): void { this.selectedPluginId.set(pluginId); } grantAll(entry: RegisteredPlugin): void { this.capabilities.grantAll(entry.manifest); } toggleCapability(entry: RegisteredPlugin, capability: PluginCapabilityId): void { if (this.capabilities.has(entry.manifest.id, capability)) { this.capabilities.revoke(entry.manifest.id, capability); return; } this.capabilities.grant(entry.manifest.id, capability); } async activateAll(): Promise { this.busyAll.set(true); try { await this.host.activateReadyPlugins(); } finally { this.busyAll.set(false); } } async reload(entry: RegisteredPlugin): Promise { this.busyPluginId.set(entry.manifest.id); try { await this.host.reloadPlugin(entry.manifest.id); } finally { this.busyPluginId.set(null); } } async activate(entry: RegisteredPlugin): Promise { this.busyPluginId.set(entry.manifest.id); try { await this.host.activatePluginById(entry.manifest.id); } finally { this.busyPluginId.set(null); } } async unload(entry: RegisteredPlugin): Promise { this.busyPluginId.set(entry.manifest.id); try { await this.host.deactivatePlugin(entry.manifest.id, { forgetActivation: true }); } finally { this.busyPluginId.set(null); } } setEnabled(entry: RegisteredPlugin, enabled: boolean): void { this.registry.setEnabled(entry.manifest.id, enabled); } isSelected(entry: RegisteredPlugin): boolean { return this.selectedPlugin()?.manifest.id === entry.manifest.id; } isActive(entry: RegisteredPlugin): boolean { return this.host.isPluginActive(entry.manifest.id); } close(): void { this.closed.emit(); } trackEntry(index: number, entry: RegisteredPlugin): string { return entry.manifest.id; } trackCapability(index: number, capability: PluginCapabilityId): string { return capability; } private entryBelongsToScope(entry: RegisteredPlugin): boolean { return getPluginInstallScope(entry.manifest) === this.scope(); } private hasVisiblePlugin(pluginId: string): boolean { return this.entries().some((entry) => entry.manifest.id === pluginId); } }