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.filters')}

+

{tr('pluginManager.filterResults', { visible: visiblePlugins.length, total: totalPlugins })}

+
+ +
+
+ + + + + {#if hasReliableSourceMetadata} + + {/if} +
+ + {#if filterPermissions.length > 0} +
+ {tr('pluginManager.filterPermissions')} +
+ {#each filterPermissions as permission} + + {/each} +
+
+ {/if} + + {#if filterCapabilities.length > 0} +
+ {tr('pluginManager.filterCapabilities')} +
+ {#each filterCapabilities as capability} + + {/each} +
+
+ {/if} +
+ {#if plugins.length === 0 && missingInstalled.length === 0}
@@ -360,9 +497,13 @@

{tr('pluginManager.installHint')}

+ {:else if visiblePlugins.length === 0} +
+

{tr('pluginManager.filterEmpty')}

+
{:else}
- {#each plugins as p} + {#each visiblePlugins as p} sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} /> {/each}
@@ -577,6 +718,99 @@ color: #f4f7fb; font-size: 0.88rem; } + .plugin-filters { + margin-bottom: 1rem; + padding: 0.75rem; + border: 1px solid #0f3460; + border-radius: 8px; + background: #121a2c; + } + .filter-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; + } + .filter-header h3 { + margin: 0; + color: #e0e0f0; + font-size: 0.9rem; + } + .filter-header p { + margin: 0.2rem 0 0; + color: #a0a0b8; + font-size: 0.78rem; + } + .filter-reset { + flex: 0 0 auto; + padding: 0.35rem 0.65rem; + border: 1px solid #533483; + border-radius: 5px; + background: #16213e; + color: #e0e0e0; + cursor: pointer; + font-size: 0.78rem; + } + .filter-reset:hover:not(:disabled) { background: #0f3460; } + .filter-reset:disabled { cursor: not-allowed; opacity: 0.5; } + .filter-grid { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + } + .filter-select-label { + display: grid; + gap: 0.28rem; + min-width: 10rem; + color: #a0a0b8; + font-size: 0.76rem; + font-weight: 600; + } + .filter-select { + min-height: 2rem; + padding: 0.3rem 1.9rem 0.3rem 0.55rem; + border: 1px solid #0f3460; + border-radius: 5px; + background: #16213e; + color: #e0e0e0; + font: inherit; + } + .filter-select:focus { outline: 2px solid #533483; outline-offset: 1px; } + .filter-options { + min-width: 0; + margin: 0.75rem 0 0; + padding: 0.55rem 0.65rem 0.65rem; + border: 1px solid rgba(15, 52, 96, 0.85); + border-radius: 6px; + } + .filter-options legend { + padding: 0 0.25rem; + color: #a0a0b8; + font-size: 0.76rem; + font-weight: 600; + } + .filter-option-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.75rem; + } + .filter-option-list label { + display: inline-flex; + align-items: center; + gap: 0.28rem; + min-width: 0; + color: #c6c6d8; + font-size: 0.75rem; + } + .filter-option-list input { accent-color: #6c4fa3; } + .filter-option-list code { + max-width: min(31rem, 72vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .filter-empty { margin-bottom: 1.5rem; } .empty { padding: 2rem; text-align: center; color: #a0a0b8; background: #16213e; border-radius: 8px; border: 1px dashed #0f3460; @@ -651,6 +885,19 @@ grid-template-columns: 1fr; } + .filter-header { + align-items: stretch; + flex-direction: column; + } + + .filter-reset { + width: 100%; + } + + .filter-select-label { + flex: 1 1 10rem; + } + .modal { width: min(880px, calc(100vw - 2rem)); height: min(680px, calc(100vh - 2rem)); diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index 7df86b8..dfa4f4d 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -4276,8 +4276,9 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ getPluginState: function (pluginId) { return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null; }, - addSyntheticPlugins: function (count) { + addSyntheticPlugins: function (count, source) { var total = Number(count || 0); + var pluginSource = source === 'official' || source === 'local' || source === 'third-party' ? source : 'third-party'; for (var i = 1; i <= total; i++) { var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0'); pluginStates[id] = { @@ -4290,7 +4291,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ version: '0.0.' + i, apiVersion: '0.1.0', description: 'Synthetic plugin used by frontend layout tests.', - source: 'test', + source: pluginSource, provides: ['verstak/synthetic-layout-' + i + '/v1'], requires: [], optionalRequires: [], @@ -4310,7 +4311,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ vaultPluginState.enabledPlugins.push(id); } if (!vaultPluginState.desiredPlugins.some(function (p) { return p.id === id; })) { - vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: 'test' }); + vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: pluginSource }); } } },