diff --git a/frontend/e2e/localization.spec.js b/frontend/e2e/localization.spec.js
index 0af89cd..49acfb1 100644
--- a/frontend/e2e/localization.spec.js
+++ b/frontend/e2e/localization.spec.js
@@ -33,6 +33,9 @@ test.describe('Desktop localization', () => {
await expect(pluginManager).toContainText('Зарегистрировано возможностей:');
await expect(pluginManager.locator('.registry-section')).toContainText('Реестр возможностей');
await expect(pluginManager.locator('.registry-section')).toContainText('Возможность');
+ await expect(pluginManager.locator('.plugin-filters')).toContainText('Фильтры');
+ await expect(pluginManager.locator('[data-plugin-filter-results]')).toContainText('Показано плагинов:');
+ await expect(pluginManager.locator('[data-plugin-filter="status"]')).toContainText('Выключенные');
await expect(pluginManager).not.toContainText('Workspace-scoped');
await page.reload();
diff --git a/frontend/e2e/plugin-manager-layout.spec.js b/frontend/e2e/plugin-manager-layout.spec.js
index 3ce1d31..76ba4d3 100644
--- a/frontend/e2e/plugin-manager-layout.spec.js
+++ b/frontend/e2e/plugin-manager-layout.spec.js
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
-import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
+import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager, setPluginStatus } from './helpers.js';
test.describe('E: Plugin Manager layout', () => {
let consoleCollector;
@@ -88,6 +88,50 @@ test.describe('E: Plugin Manager layout', () => {
await expect(risk).toContainText('elevated permissions');
});
+ test('plugin manager filters by state, declared permissions, capabilities, settings and source', async ({ page }) => {
+ await setPluginStatus(page, 'verstak.todo', 'disabled', false);
+ await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(1, 'third-party'));
+ await openPluginManager(page);
+
+ const results = page.locator('[data-plugin-filter-results]');
+ await expect(results).toContainText('Showing');
+
+ await page.locator('[data-plugin-filter="status"]').selectOption('disabled');
+ await expect(page.locator('.plugin-card')).toHaveCount(1);
+ await expect(page.locator('.plugin-card')).toContainText('Todos');
+ await page.locator('button.reload-btn').click();
+ await expect(page.locator('[data-plugin-filter="status"]')).toHaveValue('disabled');
+ await expect(page.locator('.plugin-card')).toHaveCount(1);
+
+ await page.locator('[data-plugin-filter-reset]').click();
+ await page.locator('[data-plugin-filter-permission="notifications.schedule"]').check();
+ await expect(page.locator('.plugin-card')).toHaveCount(1);
+ await expect(page.locator('.plugin-card')).toContainText('Todos');
+ await page.locator('[data-plugin-filter-permission="secrets.read"]').check();
+ await expect(page.locator('.plugin-card')).toHaveCount(2);
+ await page.locator('[data-plugin-filter-permission="secrets.read"]').uncheck();
+
+ await page.locator('[data-plugin-filter-reset]').click();
+ await page.locator('[data-plugin-filter-capability="verstak/core/notifications/v1"]').check();
+ await expect(page.locator('.plugin-card')).toHaveCount(1);
+ await expect(page.locator('.plugin-card')).toContainText('Todos');
+ await page.locator('[data-plugin-filter-capability="secret-store"]').check();
+ await expect(page.locator('.plugin-card')).toHaveCount(2);
+ await page.locator('[data-plugin-filter-capability="secret-store"]').uncheck();
+
+ await page.locator('[data-plugin-filter-reset]').click();
+ await page.locator('[data-plugin-filter="settings"]').selectOption('with');
+ await expect(page.locator('.plugin-card').filter({ hasText: 'Platform Test' })).toBeVisible();
+ await expect(page.locator('.plugin-card').filter({ hasText: 'Default Editor' })).toHaveCount(0);
+
+ await page.locator('[data-plugin-filter="source"]').selectOption('third-party');
+ await expect(page.locator('.plugin-card')).toHaveCount(0);
+ await expect(page.locator('[data-plugin-filter-empty]')).toBeVisible();
+
+ await page.locator('[data-plugin-filter-reset]').click();
+ await expect(page.locator('.plugin-card')).toHaveCount(13);
+ });
+
test('workspace selection keeps exactly one active node', async ({ page }) => {
const selected = page.locator('.wt-node.selected .wt-label');
await expect(selected).toHaveCount(1);
@@ -118,15 +162,15 @@ test.describe('E: Plugin Manager layout', () => {
});
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
- await page.locator('button[title="New workspace"]').click();
+ await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('ClientA');
- await modal.getByRole('button', { name: 'Create workspace' }).click();
+ await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' });
- await client.locator('button[title="Rename workspace"]').click();
+ await client.locator('button[title="Rename Deal"]').click();
await page.locator('.wt-rename').fill('ClientB');
await page.locator('button[title="Save rename"]').click();
@@ -134,7 +178,7 @@ test.describe('E: Plugin Manager layout', () => {
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' });
- await renamed.locator('button[title="Trash workspace"]').click();
+ await renamed.locator('button[title="Move Deal to trash"]').click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
});
diff --git a/frontend/src/lib/i18n/catalogs/en.js b/frontend/src/lib/i18n/catalogs/en.js
index 9c59457..b0b8c72 100644
--- a/frontend/src/lib/i18n/catalogs/en.js
+++ b/frontend/src/lib/i18n/catalogs/en.js
@@ -54,6 +54,24 @@ export default {
'pluginManager.health': 'Plugin Health',
'pluginManager.permissionRisk': 'Permission Risk',
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
+ 'pluginManager.filters': 'Filters',
+ 'pluginManager.filterResults': 'Showing {visible} of {total} plugins',
+ 'pluginManager.filterReset': 'Reset filters',
+ 'pluginManager.filterState': 'State',
+ 'pluginManager.filterAll': 'All',
+ 'pluginManager.filterEnabled': 'Enabled',
+ 'pluginManager.filterDisabled': 'Disabled',
+ 'pluginManager.filterFailed': 'Problem',
+ 'pluginManager.filterDegraded': 'Degraded',
+ 'pluginManager.filterPermissions': 'Permissions',
+ 'pluginManager.filterCapabilities': 'Capabilities',
+ 'pluginManager.filterSettings': 'Settings',
+ 'pluginManager.filterWithSettings': 'With settings',
+ 'pluginManager.filterWithoutSettings': 'Without settings',
+ 'pluginManager.filterSource': 'Source',
+ 'pluginManager.filterOfficial': 'Official',
+ 'pluginManager.filterThirdParty': 'Third-party or local',
+ 'pluginManager.filterEmpty': 'No plugins match the selected filters.',
'pluginManager.none': 'No plugins found',
'pluginManager.scannedDirs': 'Plugin directories scanned:',
'pluginManager.userPlugins': 'user plugins',
diff --git a/frontend/src/lib/i18n/catalogs/ru.js b/frontend/src/lib/i18n/catalogs/ru.js
index ef6c77f..2ca0174 100644
--- a/frontend/src/lib/i18n/catalogs/ru.js
+++ b/frontend/src/lib/i18n/catalogs/ru.js
@@ -54,6 +54,24 @@ export default {
'pluginManager.health': 'Состояние плагинов',
'pluginManager.permissionRisk': 'Риск разрешений',
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
+ 'pluginManager.filters': 'Фильтры',
+ 'pluginManager.filterResults': 'Показано плагинов: {visible} из {total}',
+ 'pluginManager.filterReset': 'Сбросить фильтры',
+ 'pluginManager.filterState': 'Состояние',
+ 'pluginManager.filterAll': 'Все',
+ 'pluginManager.filterEnabled': 'Включённые',
+ 'pluginManager.filterDisabled': 'Выключенные',
+ 'pluginManager.filterFailed': 'С проблемами',
+ 'pluginManager.filterDegraded': 'Ограниченные',
+ 'pluginManager.filterPermissions': 'Разрешения',
+ 'pluginManager.filterCapabilities': 'Возможности',
+ 'pluginManager.filterSettings': 'Настройки',
+ 'pluginManager.filterWithSettings': 'С настройками',
+ 'pluginManager.filterWithoutSettings': 'Без настроек',
+ 'pluginManager.filterSource': 'Источник',
+ 'pluginManager.filterOfficial': 'Официальные',
+ 'pluginManager.filterThirdParty': 'Сторонние или локальные',
+ 'pluginManager.filterEmpty': 'Нет плагинов, соответствующих выбранным фильтрам.',
'pluginManager.none': 'Плагины не найдены',
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
'pluginManager.userPlugins': 'пользовательские плагины',
diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte
index 5ee70d3..6970966 100644
--- a/frontend/src/lib/plugin-manager/PluginManager.svelte
+++ b/frontend/src/lib/plugin-manager/PluginManager.svelte
@@ -33,6 +33,11 @@
let reloading = false;
let toastMessage = '';
let toastType = 'success'; // 'success' | 'error' | 'info'
+ let statusFilter = 'all';
+ let permissionFilters = [];
+ let capabilityFilters = [];
+ let settingsFilter = 'all';
+ let sourceFilter = 'all';
export let activeSettingsPluginId = '';
export let activeSettingsPanelId = '';
@@ -256,6 +261,11 @@
$: totalPerms = permissions.length;
$: statusSummary = computeStatusSummary(plugins);
$: elevatedPermissionPluginCount = computeElevatedPermissionPluginCount(plugins, permissions);
+ $: filterPermissions = collectManifestValues(plugins, 'permissions');
+ $: filterCapabilities = collectCapabilities(plugins);
+ $: hasReliableSourceMetadata = plugins.some((plugin) => sourceGroup(plugin) !== '');
+ $: visiblePlugins = filterPlugins(plugins, statusFilter, permissionFilters, capabilityFilters, settingsFilter, sourceFilter, contributions);
+ $: filtersActive = statusFilter !== 'all' || permissionFilters.length > 0 || capabilityFilters.length > 0 || settingsFilter !== 'all' || sourceFilter !== 'all';
function computeStatusSummary(pluginRows) {
return pluginRows.reduce((summary, plugin) => {
@@ -274,6 +284,69 @@
return (pluginRows || []).filter(plugin => (plugin?.manifest?.permissions || []).some(permission => dangerous.has(permission))).length;
}
+ function collectManifestValues(pluginRows, key) {
+ return Array.from(new Set((pluginRows || []).flatMap((plugin) => plugin?.manifest?.[key] || [])))
+ .sort((left, right) => left.localeCompare(right));
+ }
+
+ function collectCapabilities(pluginRows) {
+ return Array.from(new Set((pluginRows || []).flatMap((plugin) => [
+ ...(plugin?.manifest?.provides || []),
+ ...(plugin?.manifest?.requires || []),
+ ...(plugin?.manifest?.optionalRequires || []),
+ ]))).sort((left, right) => left.localeCompare(right));
+ }
+
+ function pluginStatusGroup(plugin) {
+ const status = plugin?.status || '';
+ if (status === 'failed' || status === 'incompatible' || status === 'missing-required-capability') return 'failed';
+ if (status === 'degraded') return 'degraded';
+ if (status === 'disabled' || plugin?.enabled === false) return 'disabled';
+ return 'enabled';
+ }
+
+ function sourceGroup(plugin) {
+ const source = plugin?.manifest?.source;
+ if (source === 'official') return 'official';
+ if (source === 'local' || source === 'third-party') return 'third-party';
+ return '';
+ }
+
+ function hasSettings(plugin, activeContributions) {
+ const pluginId = plugin?.manifest?.id;
+ return Boolean(pluginId && (activeContributions.settingsPanels || []).some((panel) => panel.pluginId === pluginId));
+ }
+
+ function includesAny(values, expected) {
+ return expected.length === 0 || expected.some((value) => values.includes(value));
+ }
+
+ function filterPlugins(pluginRows, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
+ return pluginRows.filter((plugin) => matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions));
+ }
+
+ function matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
+ if (activeStatusFilter !== 'all' && pluginStatusGroup(plugin) !== activeStatusFilter) return false;
+ if (!includesAny(plugin?.manifest?.permissions || [], activePermissionFilters)) return false;
+ const declaredCapabilities = [
+ ...(plugin?.manifest?.provides || []),
+ ...(plugin?.manifest?.requires || []),
+ ...(plugin?.manifest?.optionalRequires || []),
+ ];
+ if (!includesAny(declaredCapabilities, activeCapabilityFilters)) return false;
+ if (activeSettingsFilter === 'with' && !hasSettings(plugin, activeContributions)) return false;
+ if (activeSettingsFilter === 'without' && hasSettings(plugin, activeContributions)) return false;
+ return activeSourceFilter === 'all' || sourceGroup(plugin) === activeSourceFilter;
+ }
+
+ function resetFilters() {
+ statusFilter = 'all';
+ permissionFilters = [];
+ capabilityFilters = [];
+ settingsFilter = 'all';
+ sourceFilter = 'all';
+ }
+
function closeSettings() {
settingsPanel = null;
settingsPluginId = '';
@@ -345,6 +418,70 @@
+ {tr('pluginManager.filterResults', { visible: visiblePlugins.length, total: totalPlugins })}{tr('pluginManager.filters')}
+
{tr('pluginManager.filterEmpty')}
+