From 9d06642ca678bab14256523fb70452fc251cc26d Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 14 Jul 2026 20:10:29 +0800 Subject: [PATCH] feat: explain unavailable template tools --- frontend/e2e/workspace-templates.spec.js | 19 +++ frontend/src/lib/i18n/catalogs/en.js | 12 ++ frontend/src/lib/i18n/catalogs/ru.js | 12 ++ frontend/src/lib/shell/WorkspaceTree.svelte | 128 ++++++++++++++++++-- frontend/src/lib/test/wails-mock.js | 1 + 5 files changed, 162 insertions(+), 10 deletions(-) diff --git a/frontend/e2e/workspace-templates.spec.js b/frontend/e2e/workspace-templates.spec.js index c298f6b..ebf97a2 100644 --- a/frontend/e2e/workspace-templates.spec.js +++ b/frontend/e2e/workspace-templates.spec.js @@ -77,6 +77,25 @@ test.describe('Workspace templates', () => { await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible(); }); + test('template explains an unavailable plugin and warns after incomplete creation', async ({ page }) => { + await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false)); + const modal = await openCreateModal(page); + await modal.locator('[data-workspace-template]').selectOption('project'); + + const todo = modal.locator('[data-workspace-template-tool="verstak.todo"]'); + await expect(todo).toContainText('Todos'); + await expect(todo).toContainText('Plugin is disabled'); + await expect(todo).toHaveAttribute('data-template-tool-status', 'unavailable'); + + await modal.locator('[data-workspace-name]').fill('ProjectWithWarning'); + await modal.getByRole('button', { name: 'Create workspace' }).click(); + + const warning = page.locator('[data-workspace-template-warning]'); + await expect(warning).toContainText('ProjectWithWarning'); + await expect(warning).toContainText('Todos'); + await expect(warning).toContainText('Plugin is disabled'); + }); + test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => { let modal = await openCreateModal(page); await modal.locator('[data-workspace-name]').fill('AdminSpace'); diff --git a/frontend/src/lib/i18n/catalogs/en.js b/frontend/src/lib/i18n/catalogs/en.js index 83d7a5a..f1ac9b1 100644 --- a/frontend/src/lib/i18n/catalogs/en.js +++ b/frontend/src/lib/i18n/catalogs/en.js @@ -123,6 +123,18 @@ export default { 'workspaceTree.namePlaceholder': 'Workspace name', 'workspaceTree.template': 'Template', 'workspaceTree.creating': 'Creating...', + 'workspaceTree.templateAvailable': 'Available', + 'workspaceTree.templateLimited': 'Available with limitations', + 'workspaceTree.templateMissingPlugin': 'Plugin is not installed', + 'workspaceTree.templatePluginDisabled': 'Plugin is disabled', + 'workspaceTree.templateCapabilityUnavailable': 'A required capability is unavailable', + 'workspaceTree.templateIncompatible': 'Plugin is incompatible with this version of Verstak', + 'workspaceTree.templateLoadFailed': 'Plugin failed to load', + 'workspaceTree.templateNotReady': 'Plugin is not ready', + 'workspaceTree.templateToolTabs': 'Creates: {tabs}', + 'workspaceTree.templateToolNoTabs': 'No Deal tab provided', + 'workspaceTree.templateWillBeIncomplete': 'This Deal will be created without the unavailable components.', + 'workspaceTree.templateIncompleteCreated': '«{name}» was created with unavailable components.', 'command.openOverview': 'Open Overview', 'command.openFiles': 'Open Files', 'command.openActivity': 'Open Activity', diff --git a/frontend/src/lib/i18n/catalogs/ru.js b/frontend/src/lib/i18n/catalogs/ru.js index 157243b..e34e3d0 100644 --- a/frontend/src/lib/i18n/catalogs/ru.js +++ b/frontend/src/lib/i18n/catalogs/ru.js @@ -123,6 +123,18 @@ export default { 'workspaceTree.namePlaceholder': 'Название рабочего пространства', 'workspaceTree.template': 'Шаблон', 'workspaceTree.creating': 'Создание...', + 'workspaceTree.templateAvailable': 'Доступен', + 'workspaceTree.templateLimited': 'Доступен с ограничениями', + 'workspaceTree.templateMissingPlugin': 'Плагин не установлен', + 'workspaceTree.templatePluginDisabled': 'Плагин выключен', + 'workspaceTree.templateCapabilityUnavailable': 'Не хватает обязательной возможности', + 'workspaceTree.templateIncompatible': 'Плагин несовместим с этой версией Верстака', + 'workspaceTree.templateLoadFailed': 'Плагин не удалось загрузить', + 'workspaceTree.templateNotReady': 'Плагин ещё не готов к работе', + 'workspaceTree.templateToolTabs': 'Создаёт вкладки: {tabs}', + 'workspaceTree.templateToolNoTabs': 'Не предоставляет вкладку Дела', + 'workspaceTree.templateWillBeIncomplete': 'Дело будет создано без недоступных компонентов.', + 'workspaceTree.templateIncompleteCreated': 'Дело «{name}» создано с недоступными компонентами.', 'command.openOverview': 'Открыть обзор', 'command.openFiles': 'Открыть файлы', 'command.openActivity': 'Открыть активность', diff --git a/frontend/src/lib/shell/WorkspaceTree.svelte b/frontend/src/lib/shell/WorkspaceTree.svelte index 7338e0e..75c3669 100644 --- a/frontend/src/lib/shell/WorkspaceTree.svelte +++ b/frontend/src/lib/shell/WorkspaceTree.svelte @@ -18,8 +18,11 @@ let newWorkspaceName = ''; let workspaceTemplates = []; let templatePluginNames = {}; + let templatePlugins = {}; + let templateCapabilities = new Set(); let selectedTemplateId = 'default'; let createError = ''; + let templateWarning = null; let templatesLoading = false; let creating = false; let renamingId = ''; @@ -61,17 +64,67 @@ } $: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null; + $: selectedTemplateTools = (selectedTemplate?.workspaceTools || []).map((pluginId) => ( + templateToolState(pluginId, templatePlugins, templateCapabilities, templatePluginNames, tr) + )); + $: selectedTemplateIssues = selectedTemplateTools.filter(tool => tool.status !== 'available'); - function toolLabel(pluginId) { - return templatePluginNames[pluginId] || String(pluginId || '').replace(/^verstak\./, ''); + function toolLabel(pluginId, names = templatePluginNames) { + return names[pluginId] || String(pluginId || '').replace(/^verstak\./, ''); + } + + function templateToolState(pluginId, plugins, capabilities, names, translate) { + const plugin = plugins[pluginId]; + if (!plugin) { + return { + pluginId, + name: toolLabel(pluginId, names), + tabs: [], + status: 'unavailable', + reason: translate('workspaceTree.templateMissingPlugin'), + }; + } + + const manifest = plugin.manifest || {}; + const tabs = Array.isArray(manifest.contributes?.workspaceItems) + ? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean) + : []; + const pluginStatus = String(plugin.status || '').toLowerCase(); + const missingCapability = Array.isArray(manifest.requires) + && manifest.requires.some(capabilityId => !capabilities.has(capabilityId)); + let status = 'available'; + let reason = translate('workspaceTree.templateAvailable'); + + if (!plugin.enabled || pluginStatus === 'disabled') { + status = 'unavailable'; + reason = translate('workspaceTree.templatePluginDisabled'); + } else if (pluginStatus === 'missing-required-capability' || missingCapability) { + status = 'unavailable'; + reason = translate('workspaceTree.templateCapabilityUnavailable'); + } else if (pluginStatus === 'incompatible') { + status = 'unavailable'; + reason = translate('workspaceTree.templateIncompatible'); + } else if (pluginStatus === 'failed') { + status = 'unavailable'; + reason = translate('workspaceTree.templateLoadFailed'); + } else if (pluginStatus === 'degraded') { + status = 'limited'; + reason = translate('workspaceTree.templateLimited'); + } else if (pluginStatus !== 'loaded') { + status = 'unavailable'; + reason = translate('workspaceTree.templateNotReady'); + } + + return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason }; } async function loadWorkspaceTemplates() { templatesLoading = true; try { - const [templates, plugins] = await Promise.all([ + const [templates, plugins, capabilities] = await Promise.all([ App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [], App.GetPlugins ? App.GetPlugins() : [], + App.GetCapabilities ? App.GetCapabilities() : [], ]); const [list, err] = resultOrError(templates, []); if (err) { @@ -83,12 +136,20 @@ await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => ( i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {}) ))); - templatePluginNames = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)).reduce((names, plugin) => { + const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)); + templatePluginNames = localizedPlugins.reduce((names, plugin) => { const id = plugin?.manifest?.id; const name = plugin?.manifest?.name; if (id && name) names[id] = name; return names; }, {}); + templatePlugins = localizedPlugins.reduce((result, plugin) => { + const id = plugin?.manifest?.id; + if (id) result[id] = plugin; + return result; + }, {}); + const [capabilityList] = resultOrError(capabilities, []); + templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean)); if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) { selectedTemplateId = workspaceTemplates[0]?.id || ''; } @@ -177,6 +238,11 @@ createError = tr('workspaceTree.chooseTemplate'); return; } + const creationIssues = selectedTemplateIssues.map(tool => ({ + pluginId: tool.pluginId, + name: tool.name, + reason: tool.reason, + })); creating = true; createError = ''; const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null); @@ -191,13 +257,14 @@ await loadWorkspaces(); const created = workspaces.find((ws) => wsName(ws) === name); if (created) await selectWorkspace(created); + templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null; } - function openCreateDialog() { + async function openCreateDialog() { showCreate = true; newWorkspaceName = ''; createError = ''; - if (!workspaceTemplates.length && !templatesLoading) loadWorkspaceTemplates(); + await loadWorkspaceTemplates(); } function closeCreateDialog() { @@ -207,6 +274,10 @@ createError = ''; } + function dismissTemplateWarning() { + templateWarning = null; + } + function startRename(workspace) { renamingId = wsName(workspace); renameValue = renamingId; @@ -304,6 +375,18 @@ {/each} + {#if templateWarning} + + {/if} + {#if showCreate}