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}
+