feat: plugins v1

This commit is contained in:
2026-04-29 01:14:14 +02:00
parent ec3802ade6
commit 6920f93b41
86 changed files with 9036 additions and 14 deletions

View File

@@ -0,0 +1,449 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<section
class="flex h-full min-h-0 flex-col bg-background text-foreground"
data-testid="plugin-manager"
>
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Back to settings"
(click)="close()"
>
<ng-icon
name="lucideArrowLeft"
size="18"
/>
</button>
<div class="min-w-0">
<h2 class="truncate text-base font-semibold">Plugins</h2>
<p class="truncate text-xs text-muted-foreground">Local runtime, store install, capabilities, logs, extension points.</p>
</div>
</div>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyAll()"
(click)="activateAll()"
>
<ng-icon
name="lucidePlay"
size="16"
/>
Activate ready plugins
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted"
(click)="openStore()"
>
<ng-icon
name="lucideStore"
size="16"
/>
Open Plugin Store
</button>
</header>
<nav
class="flex gap-2 border-b border-border px-4 py-2"
aria-label="Plugin manager sections"
>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'installed'"
(click)="setTab('installed')"
>
<ng-icon
name="lucidePackage"
size="16"
/>
Installed
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'extensions'"
(click)="setTab('extensions')"
>
<ng-icon
name="lucideSettings"
size="16"
/>
Extension points
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'requirements'"
(click)="setTab('requirements')"
>
<ng-icon
name="lucideShield"
size="16"
/>
Requirements
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'settings'"
(click)="setTab('settings')"
>
<ng-icon
name="lucideSettings"
size="16"
/>
Settings
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'docs'"
(click)="setTab('docs')"
>
<ng-icon
name="lucidePackage"
size="16"
/>
Docs
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'logs'"
(click)="setTab('logs')"
>
<ng-icon
name="lucideBug"
size="16"
/>
Logs
</button>
</nav>
<div class="min-h-0 flex-1 overflow-auto p-4">
@switch (activeTab()) {
@case ('extensions') {
<div class="space-y-4">
<div
class="grid gap-3 md:grid-cols-2 xl:grid-cols-4"
data-testid="plugin-extension-counts"
>
@for (
item of [
{ label: 'Settings pages', value: extensionCounts().settingsPages },
{ label: 'App pages', value: extensionCounts().appPages },
{ label: 'Side panels', value: extensionCounts().sidePanels },
{ label: 'Channel sections', value: extensionCounts().channelSections },
{ label: 'Composer actions', value: extensionCounts().composerActions },
{ label: 'Profile actions', value: extensionCounts().profileActions },
{ label: 'Toolbar actions', value: extensionCounts().toolbarActions },
{ label: 'Embed renderers', value: extensionCounts().embeds }
];
track item.label
) {
<article class="rounded-lg border border-border bg-card p-3">
<p class="text-sm text-muted-foreground">{{ item.label }}</p>
<p class="mt-2 text-2xl font-semibold">{{ item.value }}</p>
</article>
}
</div>
<section
class="rounded-lg border border-border bg-card p-4"
data-testid="plugin-conflict-diagnostics"
>
<h3 class="text-sm font-semibold">Conflict diagnostics</h3>
@if (uiConflicts().length === 0) {
<p class="mt-2 text-sm text-muted-foreground">
No duplicate route, action, embed, channel, panel, or settings contribution ids detected.
</p>
} @else {
<div class="mt-3 space-y-2">
@for (conflict of uiConflicts(); track conflict.kind + conflict.contributionId) {
<div class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm">
<span class="font-medium">{{ conflict.kind }} / {{ conflict.contributionId }}</span>
<span class="text-muted-foreground"> conflicts in {{ conflict.pluginIds.join(', ') }}</span>
</div>
}
</div>
}
</section>
</div>
}
@case ('requirements') {
<div
class="space-y-3"
data-testid="plugin-server-requirements"
>
@if (requirementComparisons().length === 0) {
<p class="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
No server plugin requirements for the current room.
</p>
} @else {
@for (comparison of requirementComparisons(); track comparison.pluginId) {
<article class="rounded-lg border border-border bg-card p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 class="text-sm font-semibold">{{ comparison.installed?.title ?? comparison.pluginId }}</h3>
<p class="mt-1 text-xs text-muted-foreground">{{ comparison.pluginId }}</p>
</div>
<span class="rounded bg-muted px-2 py-1 text-xs text-muted-foreground">{{ comparison.status }}</span>
</div>
@if (comparison.requirement) {
<p class="mt-3 text-sm text-muted-foreground">Server status: {{ comparison.requirement.status }}</p>
@if (comparison.requirement.versionRange) {
<p class="mt-1 text-sm text-muted-foreground">Version range: {{ comparison.requirement.versionRange }}</p>
}
@if (comparison.requirement.reason) {
<p class="mt-1 text-sm text-muted-foreground">{{ comparison.requirement.reason }}</p>
}
}
</article>
}
}
</div>
}
@case ('settings') {
<div
class="grid gap-4 xl:grid-cols-[260px_minmax(0,1fr)]"
data-testid="plugin-generated-settings"
>
<div class="space-y-2">
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
{{ entry.manifest.title }}
</button>
}
</div>
<section class="rounded-lg border border-border bg-card p-4">
@if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
@if (selectedSettingsPages().length > 0) {
<div class="mt-4 space-y-3">
@for (page of selectedSettingsPages(); track page.id) {
<article class="rounded-md border border-border bg-background/40 p-3">
<h4 class="mb-2 text-sm font-medium">{{ page.contribution.label }}</h4>
<app-plugin-render-host [render]="page.contribution.render"></app-plugin-render-host>
</article>
}
</div>
}
@if (selectedSettingsSchema()) {
<pre class="mt-3 max-h-[420px] overflow-auto rounded-md bg-muted p-3 text-xs">{{ selectedSettingsSchema() | json }}</pre>
} @else {
<p class="mt-2 text-sm text-muted-foreground">This plugin does not declare a settings schema.</p>
}
}
</section>
</div>
}
@case ('docs') {
<div
class="grid gap-4 xl:grid-cols-[260px_minmax(0,1fr)]"
data-testid="plugin-installed-docs"
>
<div class="space-y-2">
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
{{ entry.manifest.title }}
</button>
}
</div>
<section class="rounded-lg border border-border bg-card p-4">
@if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3>
<p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p>
<div class="mt-4 flex flex-wrap gap-2">
@for (doc of selectedDocs(); track doc.label) {
<a
class="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted"
[href]="doc.url"
target="_blank"
rel="noreferrer"
>{{ doc.label }}</a
>
}
</div>
<pre class="mt-4 max-h-[420px] overflow-auto rounded-md bg-muted p-3 text-xs">{{ plugin.manifest | json }}</pre>
}
</section>
</div>
}
@case ('logs') {
<div class="space-y-3">
@if (!selectedPlugin()) {
<p class="text-sm text-muted-foreground">No plugins installed.</p>
} @else {
<div class="flex flex-wrap gap-2">
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
{{ entry.manifest.title }}
</button>
}
</div>
<div class="rounded-lg border border-border bg-card">
@if (selectedLogs().length === 0) {
<p class="p-4 text-sm text-muted-foreground">No logs for selected plugin.</p>
} @else {
@for (log of selectedLogs(); track log.timestamp) {
<div class="border-b border-border px-4 py-3 last:border-b-0">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="uppercase">{{ log.level }}</span>
<span>{{ log.timestamp | date: 'short' }}</span>
</div>
<p class="mt-1 text-sm">{{ log.message }}</p>
</div>
}
}
</div>
}
</div>
}
@default {
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<div class="space-y-3">
@if (entries().length === 0) {
<div
class="rounded-lg border border-dashed border-border p-8 text-center"
data-testid="plugin-empty-state"
>
<ng-icon
class="mx-auto text-muted-foreground"
name="lucidePackage"
size="28"
/>
<p class="mt-3 text-sm font-medium">No plugins installed.</p>
<p class="mt-1 text-sm text-muted-foreground">Use Store tab or local plugin folder discovery.</p>
</div>
} @else {
@for (entry of entries(); track trackEntry($index, entry)) {
<article
class="rounded-lg border border-border bg-card p-4"
[class.ring-2]="isSelected(entry)"
[class.ring-primary]="isSelected(entry)"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="truncate text-sm font-semibold">{{ entry.manifest.title }}</h3>
<span class="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">{{ entry.state }}</span>
<span class="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">v{{ entry.manifest.version }}</span>
</div>
<p class="mt-1 text-sm text-muted-foreground">{{ entry.manifest.description }}</p>
<p class="mt-2 text-xs text-muted-foreground">{{ entry.manifest.id }}</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
(click)="selectPlugin(entry.manifest.id)"
>
Select
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
(click)="setEnabled(entry, !entry.enabled)"
>
<ng-icon
[name]="entry.enabled ? 'lucideX' : 'lucideCheck'"
size="14"
/>
{{ entry.enabled ? 'Disable' : 'Enable' }}
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyPluginId() === entry.manifest.id"
(click)="reload(entry)"
>
<ng-icon
name="lucideRefreshCw"
size="14"
/>
Reload
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyPluginId() === entry.manifest.id"
(click)="unload(entry)"
>
<ng-icon
name="lucideX"
size="14"
/>
Unload
</button>
</div>
</div>
@if (entry.error) {
<p class="mt-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ entry.error }}</p>
}
</article>
}
}
</div>
<aside class="rounded-lg border border-border bg-card p-4">
@if (selectedPlugin(); as plugin) {
<div class="flex items-center gap-2">
<ng-icon
name="lucideShield"
size="18"
/>
<h3 class="text-sm font-semibold">Capabilities</h3>
</div>
@if ((plugin.manifest.capabilities?.length ?? 0) === 0) {
<p class="mt-3 text-sm text-muted-foreground">Plugin requests no capabilities.</p>
} @else {
<button
type="button"
class="mt-3 h-8 rounded-md border border-border px-3 text-sm hover:bg-muted"
(click)="grantAll(plugin)"
>
Grant all requested
</button>
<div class="mt-3 space-y-2">
@for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) {
<label class="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<input
type="checkbox"
class="h-4 w-4"
[checked]="capabilities.has(plugin.manifest.id, capability)"
(change)="toggleCapability(plugin, capability)"
/>
<span>{{ capability }}</span>
</label>
}
</div>
}
@if (missingCapabilities().length > 0) {
<p class="mt-3 text-xs text-muted-foreground">Missing: {{ missingCapabilities().join(', ') }}</p>
}
}
</aside>
</div>
}
}
</div>
</section>

View File

@@ -0,0 +1,208 @@
import { CommonModule } from '@angular/common';
import {
Component,
EventEmitter,
Output,
computed,
inject,
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 } 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 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<void>();
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<PluginManagerTab>('installed');
readonly busyPluginId = signal<string | null>(null);
readonly busyAll = signal(false);
readonly selectedPluginId = signal<string | null>(null);
readonly entries = this.registry.entries;
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.appPages().length,
channelSections: this.uiRegistry.channelSections().length,
composerActions: this.uiRegistry.composerActions().length,
embeds: this.uiRegistry.embeds().length,
profileActions: this.uiRegistry.profileActions().length,
settingsPages: this.uiRegistry.settingsPages().length,
sidePanels: this.uiRegistry.sidePanels().length,
toolbarActions: this.uiRegistry.toolbarActions().length
}));
readonly requirementComparisons = this.requirementState.comparisons;
readonly uiConflicts = this.uiRegistry.conflicts;
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 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') ? '/search' : this.router.url;
this.closed.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<void> {
this.busyAll.set(true);
try {
await this.host.activateReadyPlugins();
} finally {
this.busyAll.set(false);
}
}
async reload(entry: RegisteredPlugin): Promise<void> {
this.busyPluginId.set(entry.manifest.id);
try {
await this.host.reloadPlugin(entry.manifest.id);
} finally {
this.busyPluginId.set(null);
}
}
async unload(entry: RegisteredPlugin): Promise<void> {
this.busyPluginId.set(entry.manifest.id);
try {
await this.host.deactivatePlugin(entry.manifest.id);
} 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;
}
close(): void {
this.closed.emit();
}
trackEntry(index: number, entry: RegisteredPlugin): string {
return entry.manifest.id;
}
trackCapability(index: number, capability: PluginCapabilityId): string {
return capability;
}
}

View File

@@ -0,0 +1,17 @@
<main class="min-h-screen bg-background p-6 text-foreground">
<a routerLink="/search" class="text-sm text-muted-foreground hover:text-foreground">Back</a>
@if (page(); as pageRecord) {
<section class="mx-auto mt-6 max-w-5xl">
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">{{ pageRecord.pluginId }}</p>
<h1 class="mt-1 text-2xl font-semibold">{{ pageRecord.contribution.label }}</h1>
<div class="mt-6 rounded-lg border border-border bg-card p-4">
<app-plugin-render-host [render]="pageRecord.contribution.render" />
</div>
</section>
} @else {
<section class="mx-auto mt-6 max-w-2xl rounded-lg border border-border bg-card p-8 text-center">
<h1 class="text-xl font-semibold">Plugin page unavailable</h1>
<p class="mt-2 text-sm text-muted-foreground">The plugin page is not registered or the plugin is not loaded.</p>
</section>
}
</main>

View File

@@ -0,0 +1,42 @@
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import {
Component,
computed,
inject
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { map } from 'rxjs/operators';
import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service';
import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-host.component';
@Component({
selector: 'app-plugin-page-host',
standalone: true,
imports: [
CommonModule,
RouterLink,
PluginRenderHostComponent
],
templateUrl: './plugin-page-host.component.html'
})
export class PluginPageHostComponent {
readonly page = computed(() => {
const params = this.params();
if (!params?.pluginId || !params.pageId) {
return null;
}
return this.uiRegistry.appPageRecords().find((record) =>
record.pluginId === params.pluginId && record.contributionKey === params.pageId
) ?? null;
});
private readonly route = inject(ActivatedRoute);
private readonly uiRegistry = inject(PluginUiRegistryService);
private readonly params = toSignal(this.route.paramMap.pipe(map((params) => ({
pageId: params.get('pageId'),
pluginId: params.get('pluginId')
}))));
}

View File

@@ -0,0 +1,44 @@
import {
Component,
ElementRef,
effect,
input,
viewChild
} from '@angular/core';
export type PluginRenderable = () => HTMLElement | string;
@Component({
selector: 'app-plugin-render-host',
standalone: true,
template: '<div #host></div>'
})
export class PluginRenderHostComponent {
readonly render = input.required<PluginRenderable>();
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
constructor() {
effect(() => {
this.renderContribution(this.render());
});
}
private renderContribution(render: PluginRenderable): void {
const hostElement = this.host().nativeElement;
hostElement.replaceChildren();
try {
const rendered = render();
if (typeof rendered === 'string') {
hostElement.textContent = rendered;
return;
}
hostElement.appendChild(rendered);
} catch (error) {
hostElement.textContent = error instanceof Error ? error.message : 'Plugin contribution failed to render';
}
}
}

View File

@@ -0,0 +1,314 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
<main
class="plugin-store"
data-testid="plugin-store-page"
>
<header class="plugin-store__topbar">
<div class="plugin-store__title-row">
<button
type="button"
(click)="goBack()"
class="plugin-store__icon-button"
title="Back to app"
>
<ng-icon name="lucideArrowLeft" />
</button>
<div class="plugin-store__brand-icon">
<ng-icon name="lucideStore" />
</div>
<div class="plugin-store__title-copy">
<h1>Plugin Store</h1>
<p>{{ installedCount() }} installed · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources</p>
</div>
</div>
<div class="plugin-store__top-actions">
<button
type="button"
(click)="openManager()"
class="plugin-store__secondary-button"
>
<ng-icon name="lucideSettings" />
Manage Plugins
</button>
<button
type="button"
(click)="refreshSources()"
[disabled]="store.isLoading()"
class="plugin-store__secondary-button"
>
<ng-icon
name="lucideRefreshCw"
[class.is-spinning]="store.isLoading()"
/>
Refresh
</button>
</div>
</header>
<section class="plugin-store__source-strip">
<div class="plugin-store__source-form">
<label class="plugin-store__input-shell plugin-store__source-input">
<input
type="url"
[(ngModel)]="newSourceUrl"
(keyup.enter)="addSourceUrl()"
placeholder="https://example.com/plugins.json"
aria-label="Plugin source manifest URL"
/>
</label>
<button
type="button"
(click)="addSourceUrl()"
[disabled]="!newSourceUrl.trim() || store.isLoading()"
class="plugin-store__primary-button"
>
<ng-icon name="lucidePlus" />
Add Source
</button>
</div>
@if (sourceError()) {
<p class="plugin-store__error-text">{{ sourceError() }}</p>
}
</section>
<div class="plugin-store__layout">
<aside
class="plugin-store__rail"
aria-label="Plugin sources"
>
<section class="plugin-store__panel">
<div class="plugin-store__panel-header">
<h2>Sources</h2>
<span>{{ sourceCount() }}</span>
</div>
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === null"
(click)="selectSource(null)"
>
<span>All sources</span>
<strong>{{ totalSourcePlugins() }}</strong>
</button>
@for (source of store.sources(); track source.url) {
<div
class="plugin-store__source-row"
[class.has-error]="!!source.error"
>
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === source.url"
(click)="selectSource(source.url)"
>
<span>{{ source.title || source.url }}</span>
<strong>{{ source.plugins.length }}</strong>
</button>
<button
type="button"
(click)="removeSourceUrl(source.url)"
class="plugin-store__icon-button plugin-store__icon-button--danger"
title="Remove source"
>
<ng-icon name="lucideTrash2" />
</button>
</div>
@if (source.error) {
<p class="plugin-store__source-error">{{ source.error }}</p>
}
}
@for (sourceUrl of pendingSourceUrls(); track sourceUrl) {
<div class="plugin-store__source-row">
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === sourceUrl"
(click)="selectSource(sourceUrl)"
>
<span>{{ sourceUrl }}</span>
<strong>0</strong>
</button>
<button
type="button"
(click)="removeSourceUrl(sourceUrl)"
class="plugin-store__icon-button plugin-store__icon-button--danger"
title="Remove source"
>
<ng-icon name="lucideTrash2" />
</button>
</div>
}
</section>
<section class="plugin-store__panel">
<div class="plugin-store__panel-header">
<h2>Filters</h2>
</div>
<button
type="button"
class="plugin-store__toggle-button"
[class.is-active]="showInstalledOnly()"
(click)="toggleInstalledOnly()"
>
<span>Installed only</span>
<strong>{{ installedCount() }}</strong>
</button>
</section>
</aside>
<section
class="plugin-store__catalog"
aria-label="Available plugins"
>
<div class="plugin-store__toolbar">
<label class="plugin-store__input-shell plugin-store__search">
<ng-icon name="lucideSearch" />
<input
type="search"
[ngModel]="searchTerm()"
(ngModelChange)="searchTerm.set($event)"
placeholder="Search plugins, authors, ids"
aria-label="Search plugins"
/>
</label>
<div class="plugin-store__count">{{ filteredPlugins().length }} shown</div>
</div>
@if (actionError()) {
<p class="plugin-store__error-banner">{{ actionError() }}</p>
}
@if (readmeError()) {
<p class="plugin-store__error-banner">{{ readmeError() }}</p>
}
@if (filteredPlugins().length > 0) {
<div class="plugin-store__grid">
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
<article class="plugin-card">
<div class="plugin-card__media">
@if (plugin.imageUrl) {
<img
[src]="plugin.imageUrl"
[alt]="plugin.title"
(error)="hideBrokenImage($event)"
/>
} @else {
<ng-icon name="lucidePackage" />
}
</div>
<div class="plugin-card__body">
<div class="plugin-card__header">
<div>
<h2>{{ plugin.title }}</h2>
<p>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</p>
</div>
@if (store.getInstallState(plugin) === 'updateAvailable') {
<span class="plugin-card__badge">Update</span>
} @else if (store.getInstallState(plugin) === 'installed') {
<span class="plugin-card__badge plugin-card__badge--installed">Installed</span>
}
</div>
<p class="plugin-card__description">{{ plugin.description }}</p>
<div class="plugin-card__meta">
<span>{{ plugin.id }}</span>
<span>{{ plugin.sourceTitle || plugin.sourceUrl }}</span>
</div>
<div class="plugin-card__actions">
<button
type="button"
(click)="runPrimaryAction(plugin)"
[disabled]="isPrimaryActionDisabled(plugin)"
class="plugin-store__primary-button plugin-card__primary-action"
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall'"
>
<ng-icon
[name]="primaryActionIcon(plugin)"
[class.is-spinning]="isPluginBusy(plugin)"
/>
{{ store.getActionLabel(plugin) }}
</button>
@if (plugin.readmeUrl) {
<button
type="button"
(click)="loadReadme(plugin)"
class="plugin-store__text-button"
title="Load readme"
>
{{ isReadmeLoading(plugin) ? 'Loading' : 'Readme' }}
</button>
}
@if (plugin.githubUrl) {
<button
type="button"
(click)="openExternal(plugin.githubUrl)"
class="plugin-store__icon-button"
title="Open GitHub"
>
<ng-icon name="lucideExternalLink" />
</button>
}
</div>
</div>
</article>
}
</div>
} @else {
<section class="plugin-store__empty">
<ng-icon name="lucidePackage" />
<h2>No plugins found</h2>
<p>{{ sourceCount() ? 'Adjust filters or add another source manifest.' : 'Add a plugin source manifest URL to populate the catalog.' }}</p>
</section>
}
</section>
@if (readme()) {
<aside
class="plugin-store__readme"
aria-label="Plugin readme"
>
<div class="plugin-store__readme-header">
<div>
<p>Readme</p>
<h2>{{ readme()!.title }}</h2>
@if (selectedReadmePlugin(); as plugin) {
<span>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</span>
}
</div>
<button
type="button"
(click)="closeReadme()"
class="plugin-store__icon-button"
title="Close readme"
>
<ng-icon name="lucideX" />
</button>
</div>
<pre>{{ readme()!.markdown }}</pre>
<button
type="button"
(click)="openExternal(readme()!.url)"
class="plugin-store__secondary-button plugin-store__readme-link"
>
<ng-icon name="lucideExternalLink" />
Open source readme
</button>
</aside>
}
</div>
</main>

View File

@@ -0,0 +1,490 @@
:host {
display: block;
min-height: 100%;
}
.plugin-store {
min-height: calc(100vh - 2.5rem);
padding: 1rem clamp(0.75rem, 1.6vw, 1.5rem);
color: hsl(var(--foreground));
background: hsl(var(--background));
}
.plugin-store__topbar,
.plugin-store__title-row,
.plugin-store__top-actions,
.plugin-store__source-form,
.plugin-store__toolbar,
.plugin-store__source-row,
.plugin-store__source-filter,
.plugin-store__toggle-button,
.plugin-card__actions,
.plugin-card__meta,
.plugin-store__panel-header {
display: flex;
align-items: center;
}
.plugin-store__topbar {
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.875rem;
border-bottom: 1px solid hsl(var(--border));
}
.plugin-store__title-row,
.plugin-store__top-actions,
.plugin-store__source-form,
.plugin-store__toolbar,
.plugin-card__actions,
.plugin-card__meta {
gap: 0.625rem;
}
.plugin-store__title-copy,
.plugin-store__title-copy h1,
.plugin-store__title-copy p,
.plugin-store__source-filter span,
.plugin-store__count,
.plugin-card__header h2,
.plugin-card__header p,
.plugin-card__meta span,
.plugin-store__readme-header h2,
.plugin-store__readme-header span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plugin-store__title-copy h1 {
margin: 0;
font-size: 1.35rem;
line-height: 1.8rem;
}
.plugin-store__title-copy p,
.plugin-store__count,
.plugin-card__header p,
.plugin-card__description,
.plugin-card__meta,
.plugin-store__source-error,
.plugin-store__error-text,
.plugin-store__readme-header span {
margin: 0;
color: hsl(var(--muted-foreground));
font-size: 0.8125rem;
}
.plugin-store__brand-icon,
.plugin-store__icon-button {
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 0.5rem;
}
.plugin-store__brand-icon {
width: 2.25rem;
height: 2.25rem;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.plugin-store__icon-button {
width: 2rem;
height: 2rem;
border: 1px solid hsl(var(--border));
color: hsl(var(--muted-foreground));
background: transparent;
}
.plugin-store__icon-button:hover,
.plugin-store__secondary-button:hover,
.plugin-store__text-button:hover,
.plugin-store__source-filter:hover,
.plugin-store__toggle-button:hover {
color: hsl(var(--foreground));
background: hsl(var(--secondary));
}
.plugin-store__icon-button--danger:hover {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 0.1);
}
.plugin-store__primary-button,
.plugin-store__secondary-button,
.plugin-store__text-button {
display: inline-flex;
min-height: 2rem;
align-items: center;
justify-content: center;
gap: 0.45rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 0.4rem 0.7rem;
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--foreground));
background: hsl(var(--card));
}
.plugin-store__primary-button {
border-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
background: hsl(var(--primary));
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
ng-icon {
width: 1rem;
height: 1rem;
}
.plugin-store__brand-icon ng-icon,
.plugin-store__empty ng-icon,
.plugin-card__media ng-icon {
width: 1.4rem;
height: 1.4rem;
}
.plugin-store__source-strip {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
padding: 0.75rem 0;
}
.plugin-store__source-form {
min-width: 0;
align-items: stretch;
}
.plugin-store__input-shell {
position: relative;
display: flex;
min-width: 0;
flex: 1 1 auto;
}
.plugin-store__input-shell ng-icon {
position: absolute;
left: 0.7rem;
color: hsl(var(--muted-foreground));
}
.plugin-store__input-shell input {
width: 100%;
min-height: 2.2rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 0.45rem 0.7rem;
color: hsl(var(--foreground));
background: hsl(var(--secondary));
}
.plugin-store__search input {
padding-left: 2rem;
}
.plugin-store__layout {
display: grid;
grid-template-columns: minmax(13rem, 17rem) minmax(0, 1fr) minmax(18rem, 24rem);
gap: 0.875rem;
align-items: start;
}
.plugin-store__rail {
display: grid;
gap: 0.75rem;
position: sticky;
top: 0.75rem;
}
.plugin-store__panel,
.plugin-store__catalog,
.plugin-store__readme,
.plugin-card,
.plugin-store__empty {
min-width: 0;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--card));
}
.plugin-store__panel,
.plugin-store__catalog,
.plugin-store__readme {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
}
.plugin-store__panel {
gap: 0.375rem;
padding: 0.625rem;
}
.plugin-store__panel-header {
justify-content: space-between;
gap: 0.5rem;
}
.plugin-store__panel-header h2,
.plugin-store__readme-header h2,
.plugin-card__header h2 {
margin: 0;
}
.plugin-store__panel-header h2 {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-store__panel-header span,
.plugin-store__source-filter strong,
.plugin-store__toggle-button strong,
.plugin-card__meta span {
border-radius: 999px;
padding: 0.12rem 0.45rem;
color: hsl(var(--muted-foreground));
background: hsl(var(--secondary));
font-size: 0.72rem;
}
.plugin-store__source-row {
gap: 0.375rem;
}
.plugin-store__source-filter,
.plugin-store__toggle-button {
min-width: 0;
flex: 1 1 auto;
justify-content: space-between;
gap: 0.5rem;
border: 0;
border-radius: 0.45rem;
padding: 0.45rem 0.55rem;
color: hsl(var(--muted-foreground));
background: transparent;
text-align: left;
}
.plugin-store__source-filter.is-active,
.plugin-store__toggle-button.is-active {
color: hsl(var(--foreground));
background: hsl(var(--secondary));
}
.plugin-store__source-error,
.plugin-store__error-text,
.plugin-store__error-banner {
color: hsl(var(--destructive));
}
.plugin-store__error-banner {
margin: 0;
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 0.5rem;
padding: 0.55rem 0.7rem;
background: hsl(var(--destructive) / 0.1);
font-size: 0.8125rem;
}
.plugin-store__toolbar {
justify-content: space-between;
}
.plugin-store__search {
max-width: 30rem;
}
.plugin-store__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
gap: 0.75rem;
}
.plugin-card {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr);
overflow: hidden;
}
.plugin-card__media {
display: grid;
min-height: 100%;
place-items: center;
color: hsl(var(--muted-foreground));
background: hsl(var(--secondary));
}
.plugin-card__media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.plugin-card__body {
display: grid;
gap: 0.55rem;
padding: 0.7rem;
}
.plugin-card__header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
}
.plugin-card__header h2,
.plugin-store__readme-header h2 {
font-size: 1rem;
}
.plugin-card__badge {
border-radius: 999px;
padding: 0.18rem 0.45rem;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
font-size: 0.72rem;
font-weight: 700;
}
.plugin-card__badge--installed {
color: rgb(5 150 105);
background: rgb(5 150 105 / 0.1);
}
.plugin-card__description {
display: -webkit-box;
min-height: 2.45rem;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.plugin-card__meta,
.plugin-card__actions {
flex-wrap: wrap;
}
.plugin-card__primary-action--danger {
border-color: hsl(var(--destructive) / 0.35);
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 0.1);
}
.plugin-store__readme {
position: sticky;
top: 0.75rem;
max-height: calc(100vh - 6rem);
}
.plugin-store__readme-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
}
.plugin-store__readme-header p {
margin: 0 0 0.25rem;
color: hsl(var(--primary));
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-store__readme pre {
max-height: calc(100vh - 14rem);
overflow: auto;
margin: 0;
border-radius: 0.5rem;
padding: 0.75rem;
white-space: pre-wrap;
background: hsl(var(--secondary) / 0.5);
}
.plugin-store__empty {
display: grid;
min-height: 14rem;
place-items: center;
gap: 0.35rem;
padding: 1.5rem;
text-align: center;
}
.plugin-store__empty h2,
.plugin-store__empty p {
margin: 0;
}
.is-spinning {
animation: plugin-store-spin 0.9s linear infinite;
}
@keyframes plugin-store-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 1180px) {
.plugin-store__layout {
grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
}
.plugin-store__readme {
grid-column: 1 / -1;
position: static;
max-height: none;
}
}
@media (max-width: 820px) {
.plugin-store__topbar,
.plugin-store__source-strip,
.plugin-store__toolbar,
.plugin-store__layout {
grid-template-columns: 1fr;
}
.plugin-store__topbar,
.plugin-store__source-form,
.plugin-store__toolbar {
align-items: stretch;
flex-direction: column;
}
.plugin-store__top-actions,
.plugin-card__actions {
flex-wrap: wrap;
}
.plugin-store__rail {
position: static;
}
.plugin-store__search {
max-width: none;
}
}
@media (max-width: 560px) {
.plugin-card {
grid-template-columns: 1fr;
}
.plugin-card__media {
min-height: 4.5rem;
}
}

View File

@@ -0,0 +1,310 @@
import { CommonModule } from '@angular/common';
import {
Component,
DestroyRef,
OnInit,
computed,
inject,
signal
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowLeft,
lucideExternalLink,
lucidePlus,
lucidePackage,
lucideRefreshCw,
lucideSearch,
lucideSettings,
lucideStore,
lucideTrash2,
lucideX
} from '@ng-icons/lucide';
import { ExternalLinkService } from '../../../../core/platform';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { PluginStoreService } from '../../application/services/plugin-store.service';
import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/plugin-store.models';
@Component({
selector: 'app-plugin-store',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideArrowLeft,
lucideExternalLink,
lucidePlus,
lucidePackage,
lucideRefreshCw,
lucideSearch,
lucideSettings,
lucideStore,
lucideTrash2,
lucideX
})
],
styleUrl: './plugin-store.component.scss',
templateUrl: './plugin-store.component.html'
})
export class PluginStoreComponent implements OnInit {
readonly store = inject(PluginStoreService);
readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error));
readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id)));
readonly filteredPlugins = computed(() => {
const searchTerm = this.searchTerm().trim()
.toLowerCase();
const sourceFilter = this.selectedSourceUrl();
const showInstalled = this.showInstalledOnly();
const installedIds = this.installedIds();
const plugins = this.store.availablePlugins()
.filter((plugin) => !sourceFilter || plugin.sourceUrl === sourceFilter)
.filter((plugin) => !showInstalled || installedIds.has(plugin.id));
if (!searchTerm) {
return plugins;
}
return plugins.filter((plugin) => this.matchesSearch(plugin, searchTerm));
});
readonly installedCount = computed(() => this.store.installedPlugins().length);
readonly totalSourcePlugins = computed(() => this.store.availablePlugins().length);
readonly sourceCount = computed(() => this.store.sourceUrls().length);
readonly pendingSourceUrls = computed(() => {
const loadedUrls = new Set(this.store.sources().map((source) => source.url));
return this.store.sourceUrls().filter((sourceUrl) => !loadedUrls.has(sourceUrl));
});
readonly selectedReadmePlugin = computed(() => {
const readme = this.readme();
return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null;
});
newSourceUrl = '';
readonly searchTerm = signal('');
readonly selectedSourceUrl = signal<string | null>(null);
readonly showInstalledOnly = signal(false);
readonly sourceError = signal<string | null>(null);
readonly actionError = signal<string | null>(null);
readonly actionBusyPluginId = signal<string | null>(null);
readonly readme = signal<PluginStoreReadme | null>(null);
readonly readmeError = signal<string | null>(null);
readonly readmeLoadingPluginId = signal<string | null>(null);
private destroyed = false;
private readonly destroyRef = inject(DestroyRef);
private readonly externalLinks = inject(ExternalLinkService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly settingsModal = inject(SettingsModalService);
constructor() {
this.destroyRef.onDestroy(() => {
this.destroyed = true;
});
}
ngOnInit(): void {
if (this.store.sourceUrls().length > 0 && this.store.sources().length === 0) {
void this.refreshSources();
}
}
async addSourceUrl(): Promise<void> {
const sourceUrl = this.newSourceUrl.trim();
if (!sourceUrl) {
return;
}
this.sourceError.set(null);
try {
await this.store.addSourceUrl(sourceUrl);
if (this.destroyed) {
return;
}
this.newSourceUrl = '';
} catch (error) {
if (this.destroyed) {
return;
}
this.sourceError.set(error instanceof Error ? error.message : 'Unable to add plugin source');
}
}
async removeSourceUrl(sourceUrl: string): Promise<void> {
this.sourceError.set(null);
try {
await this.store.removeSourceUrl(sourceUrl);
if (this.selectedSourceUrl() === sourceUrl) {
this.selectedSourceUrl.set(null);
}
} catch (error) {
if (this.destroyed) {
return;
}
this.sourceError.set(error instanceof Error ? error.message : 'Unable to remove plugin source');
}
}
async refreshSources(): Promise<void> {
this.sourceError.set(null);
try {
await this.store.refreshSources();
} catch (error) {
if (this.destroyed) {
return;
}
this.sourceError.set(error instanceof Error ? error.message : 'Unable to refresh plugin sources');
}
}
async runPrimaryAction(plugin: PluginStoreEntry): Promise<void> {
const action = this.store.getActionLabel(plugin);
this.actionError.set(null);
this.actionBusyPluginId.set(plugin.id);
try {
if (action === 'Uninstall') {
this.store.uninstallPlugin(plugin.id);
} else {
await this.store.installPlugin(plugin);
}
} catch (error) {
if (this.destroyed) {
return;
}
this.actionError.set(error instanceof Error ? error.message : 'Unable to update plugin installation');
} finally {
if (!this.destroyed) {
this.actionBusyPluginId.set(null);
}
}
}
async loadReadme(plugin: PluginStoreEntry): Promise<void> {
this.readmeError.set(null);
this.readmeLoadingPluginId.set(plugin.id);
try {
const readme = await this.store.loadReadme(plugin);
if (this.destroyed) {
return;
}
this.readme.set(readme);
} catch (error) {
if (this.destroyed) {
return;
}
this.readmeError.set(error instanceof Error ? error.message : 'Unable to load readme');
} finally {
if (!this.destroyed) {
this.readmeLoadingPluginId.set(null);
}
}
}
closeReadme(): void {
this.readme.set(null);
this.readmeError.set(null);
}
goBack(): void {
void this.router.navigateByUrl(this.getReturnUrl());
}
async openManager(): Promise<void> {
await this.router.navigateByUrl(this.getReturnUrl());
this.settingsModal.open('plugins');
}
selectSource(sourceUrl: string | null): void {
this.selectedSourceUrl.set(sourceUrl);
}
toggleInstalledOnly(): void {
this.showInstalledOnly.update((value) => !value);
}
openExternal(url?: string): void {
if (url) {
this.externalLinks.open(url);
}
}
isPluginBusy(plugin: PluginStoreEntry): boolean {
return this.actionBusyPluginId() === plugin.id;
}
isReadmeLoading(plugin: PluginStoreEntry): boolean {
return this.readmeLoadingPluginId() === plugin.id;
}
isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean {
return this.isPluginBusy(plugin)
|| (!plugin.installUrl && this.store.getInstallState(plugin) !== 'installed');
}
primaryActionIcon(plugin: PluginStoreEntry): string {
const action = this.store.getActionLabel(plugin);
if (action === 'Uninstall') {
return 'lucideTrash2';
}
return 'lucidePlus';
}
trackPlugin(index: number, plugin: PluginStoreEntry): string {
return `${plugin.sourceUrl}:${plugin.id}`;
}
hideBrokenImage(event: Event): void {
const image = event.target as HTMLImageElement | null;
if (image) {
image.hidden = true;
}
}
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
return [
plugin.author,
plugin.description,
plugin.id,
plugin.sourceTitle,
plugin.title,
plugin.version
].some((value) => value?.toLowerCase().includes(searchTerm));
}
private getReturnUrl(): string {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl');
if (returnUrl?.startsWith('/') && !returnUrl.startsWith('//') && !returnUrl.startsWith('/plugin-store')) {
return returnUrl;
}
return '/search';
}
}