Files
Toju/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html
Myx 31962aeb1a fix: restore build and stabilize E2E cross-signal behavior
Revert the automated member-ordering pass that broke Angular field init
(TS2729) and disable that rule until a safe reorder strategy exists.
Fix modal/confirm dialog i18n defaults via template fallbacks, search all
active endpoints (including offline), register foreign rooms with actor
owner IDs, sync profile display names from avatar summaries, and guard
dm-chat when a private call converts to a group conversation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 12:16:40 +02:00

460 lines
20 KiB
HTML

<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<section
class="flex min-h-full flex-col bg-background text-foreground md:h-full md:min-h-0"
data-testid="plugin-manager"
>
<header class="flex flex-col gap-3 border-b border-border px-3 py-3 md:flex-row md:items-center md:justify-between md:px-4">
<div class="flex min-w-0 items-center gap-3 md:flex-1">
<button
type="button"
class="inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground md:h-8 md:w-8"
aria-label="{{ 'plugins.manager.backToSettings' | translate }}"
(click)="close()"
>
<ng-icon
name="lucideArrowLeft"
size="18"
/>
</button>
<div class="min-w-0">
<h2 class="truncate text-base font-semibold">{{ managerTitle() }}</h2>
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
</div>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 md:flex md:flex-shrink-0 md:items-center">
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyAll()"
(click)="activateAll()"
>
<ng-icon
name="lucidePlay"
size="16"
/>
{{ 'plugins.manager.activateReady' | translate }}
</button>
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="openStore()"
>
<ng-icon
name="lucideStore"
size="16"
/>
{{ 'plugins.manager.openStore' | translate }}
</button>
</div>
</header>
<nav
class="no-scrollbar flex gap-2 overflow-x-auto border-b border-border px-3 py-2 md:px-4"
aria-label="{{ 'plugins.manager.sectionsAria' | translate }}"
>
<button
type="button"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'installed'"
(click)="setTab('installed')"
>
<ng-icon
name="lucidePackage"
size="16"
/>
{{ 'plugins.manager.tabs.installed' | translate }}
</button>
<button
type="button"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'extensions'"
(click)="setTab('extensions')"
>
<ng-icon
name="lucideSettings"
size="16"
/>
{{ 'plugins.manager.tabs.extensions' | translate }}
</button>
<button
type="button"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'requirements'"
(click)="setTab('requirements')"
>
<ng-icon
name="lucideShield"
size="16"
/>
{{ 'plugins.manager.tabs.requirements' | translate }}
</button>
<button
type="button"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'settings'"
(click)="setTab('settings')"
>
<ng-icon
name="lucideSettings"
size="16"
/>
{{ 'plugins.manager.tabs.settings' | translate }}
</button>
<button
type="button"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'docs'"
(click)="setTab('docs')"
>
<ng-icon
name="lucidePackage"
size="16"
/>
{{ 'plugins.manager.tabs.docs' | translate }}
</button>
<button
type="button"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'logs'"
(click)="setTab('logs')"
>
<ng-icon
name="lucideBug"
size="16"
/>
{{ 'plugins.manager.tabs.logs' | translate }}
</button>
</nav>
<div class="min-h-0 flex-1 overflow-auto p-3 md: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 extensionCountItems(); 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">{{ 'plugins.manager.conflicts.title' | translate }}</h3>
@if (uiConflicts().length === 0) {
<p class="mt-2 text-sm text-muted-foreground">
{{ 'plugins.manager.conflicts.none' | translate }}
</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">
{{ 'plugins.manager.conflicts.conflictsIn' | translate: { plugins: 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">
{{ 'plugins.manager.requirements.empty' | translate }}
</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">
{{ 'plugins.manager.requirements.serverStatus' | translate: { status: comparison.requirement.status } }}
</p>
@if (comparison.requirement.versionRange) {
<p class="mt-1 text-sm text-muted-foreground">
{{ 'plugins.manager.requirements.versionRange' | translate: { 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="min-h-11 w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted md:min-h-0"
[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-3 md:p-4">
@if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} {{ 'plugins.manager.settings.settingsSuffix' | translate }}</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" />
</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">{{ 'plugins.manager.settings.noSchema' | translate }}</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="min-h-11 w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted md:min-h-0"
[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-3 md: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="inline-flex min-h-11 items-center rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted md:min-h-0"
[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">{{ 'plugins.manager.logs.noPlugins' | translate }}</p>
} @else {
<div class="flex flex-wrap gap-2">
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="min-h-11 rounded-md border border-border px-3 py-1 text-sm hover:bg-muted md:min-h-0"
[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">{{ 'plugins.manager.logs.noLogs' | translate }}</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-5 text-center md:p-8"
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">{{ emptyTitle() }}</p>
<p class="mt-1 text-sm text-muted-foreground">{{ emptyBody() }}</p>
</div>
} @else {
@for (entry of entries(); track trackEntry($index, entry)) {
<article
class="rounded-lg border border-border bg-card p-3 md: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="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap">
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="selectPlugin(entry.manifest.id)"
>
{{ 'plugins.manager.installed.select' | translate }}
</button>
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="setEnabled(entry, !entry.enabled)"
>
<ng-icon
[name]="entry.enabled ? 'lucideX' : 'lucideCheck'"
size="14"
/>
{{ (entry.enabled ? 'plugins.manager.installed.disable' : 'plugins.manager.installed.enable') | translate }}
</button>
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyPluginId() === entry.manifest.id || !entry.enabled || isActive(entry)"
(click)="activate(entry)"
>
<ng-icon
name="lucidePlay"
size="14"
/>
{{ 'plugins.manager.installed.activate' | translate }}
</button>
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyPluginId() === entry.manifest.id"
(click)="reload(entry)"
>
<ng-icon
name="lucideRefreshCw"
size="14"
/>
{{ 'plugins.manager.installed.reload' | translate }}
</button>
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyPluginId() === entry.manifest.id"
(click)="unload(entry)"
>
<ng-icon
name="lucideX"
size="14"
/>
{{ 'plugins.manager.installed.unload' | translate }}
</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-3 md: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">{{ 'plugins.manager.capabilities.title' | translate }}</h3>
</div>
@if ((plugin.manifest.capabilities?.length ?? 0) === 0) {
<p class="mt-3 text-sm text-muted-foreground">{{ 'plugins.manager.capabilities.none' | translate }}</p>
} @else {
<button
type="button"
class="mt-3 min-h-11 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="grantAll(plugin)"
>
{{ 'plugins.manager.capabilities.grantAll' | translate }}
</button>
<div class="mt-3 space-y-2">
@for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) {
<label class="flex min-h-11 items-center gap-2 rounded-md border border-border px-3 py-2 text-sm md:min-h-0">
<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">
{{ 'plugins.manager.capabilities.missing' | translate: { capabilities: missingCapabilities().join(', ') } }}
</p>
}
}
</aside>
</div>
}
}
</div>
</section>