diff --git a/frontend/e2e/command-palette.spec.js b/frontend/e2e/command-palette.spec.js index 29b63ce..e5f76f7 100644 --- a/frontend/e2e/command-palette.spec.js +++ b/frontend/e2e/command-palette.spec.js @@ -124,4 +124,20 @@ test.describe('Command Palette', () => { await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.modal-header h3')).toContainText('Sync'); }); + + test('keeps technical command failures out of the palette status', async ({ page }) => { + await page.evaluate(() => { + window.go.api.App.PluginSyncNow = () => Promise.resolve('[plugin:verstak.sync] sync.now failed: internal service code 42'); + }); + + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K'); + const palette = page.locator('.command-palette'); + await palette.locator('[data-command-palette-input]').fill('sync now'); + await page.keyboard.press('Enter'); + + const status = page.locator('[data-command-palette-status="error"]'); + await expect(status).toContainText('Could not run Sync Now. Please try again.'); + await expect(status).not.toContainText('[plugin:'); + await expect(status).not.toContainText('sync.now'); + }); }); diff --git a/frontend/src/lib/i18n/catalogs/en.js b/frontend/src/lib/i18n/catalogs/en.js index f1ac9b1..f0f1e4e 100644 --- a/frontend/src/lib/i18n/catalogs/en.js +++ b/frontend/src/lib/i18n/catalogs/en.js @@ -96,8 +96,8 @@ export default { 'common.loading': 'Loading...', 'common.browse': 'Browse...', 'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.', - 'vaultSelection.createError': 'Could not create vault: {error}', - 'vaultSelection.openError': 'Could not open vault: {error}', + 'vaultSelection.createError': 'Could not create the vault. Please try again.', + 'vaultSelection.openError': 'Could not open the vault. Please try again.', 'vaultSelection.chooseExisting': 'Choose or enter an existing vault.', 'vaultSelection.subtitle': 'Choose a vault to start working', 'vaultSelection.createTitle': 'Create a new vault', @@ -135,6 +135,18 @@ export default { '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.', + 'workspaceTree.templatesError': 'Could not load Deal templates. Please try again.', + 'workspaceTree.loadError': 'Could not load Deals. Please try again.', + 'workspaceTree.selectError': 'Could not select this Deal. Please try again.', + 'workspaceTree.createError': 'Could not create the Deal. Please try again.', + 'workspaceTree.renameError': 'Could not rename the Deal. Please try again.', + 'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.', + 'pluginManager.loadError': 'Could not load plugins. Please try again.', + 'pluginManager.reloadError': 'Could not reload plugins. Please try again.', + 'pluginManager.enableError': 'Could not enable the plugin. Please try again.', + 'pluginManager.disableError': 'Could not disable the plugin. Please try again.', + 'pluginManager.settingsLoadError': 'Could not load plugin settings. Please try again.', + 'pluginManager.settingsUnavailable': 'Plugin settings are unavailable.', 'command.openOverview': 'Open Overview', 'command.openFiles': 'Open Files', 'command.openActivity': 'Open Activity', @@ -146,6 +158,7 @@ export default { 'command.openPluginManager': 'Open Plugin Manager', 'command.handled': '{title} handled', 'command.result': '{title} {status}', + 'command.failed': 'Could not run {title}. Please try again.', 'command.statusHandled': 'handled', 'command.palette': 'Command Palette', 'command.run': 'Run command', @@ -166,14 +179,14 @@ export default { 'workbench.noResource': 'No resource opened', 'bundle.noFrontend': 'Plugin has no frontend bundle', 'bundle.notFound': 'Plugin not found', - 'bundle.loadFailed': 'Failed to load bundle: {error}', + 'bundle.loadFailed': 'Could not load the plugin interface. Please try again.', 'bundle.emptyContent': 'empty content', - 'bundle.executionError': 'Bundle execution error: {error}', + 'bundle.executionError': 'Could not start the plugin interface. Please try again.', 'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected', - 'bundle.componentMissing': 'Component "{component}" not found in bundle. Available: {available}', - 'bundle.mountError': 'Component mount error: {error}', + 'bundle.componentMissing': 'The requested plugin interface is unavailable.', + 'bundle.mountError': 'Could not open the plugin interface. Please try again.', 'bundle.mountUnavailable': 'Mount container not available', - 'bundle.unexpectedError': 'Unexpected error: {error}', + 'bundle.unexpectedError': 'Could not open the plugin interface. Please try again.', 'bundle.unknownError': 'Unknown error', 'bundle.frontendEntry': 'Frontend entry', 'bundle.availableComponents': 'Available components', diff --git a/frontend/src/lib/i18n/catalogs/ru.js b/frontend/src/lib/i18n/catalogs/ru.js index e34e3d0..dfc7491 100644 --- a/frontend/src/lib/i18n/catalogs/ru.js +++ b/frontend/src/lib/i18n/catalogs/ru.js @@ -96,8 +96,8 @@ export default { 'common.loading': 'Загрузка...', 'common.browse': 'Обзор...', 'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.', - 'vaultSelection.createError': 'Не удалось создать хранилище: {error}', - 'vaultSelection.openError': 'Не удалось открыть хранилище: {error}', + 'vaultSelection.createError': 'Не удалось создать хранилище. Повторите попытку.', + 'vaultSelection.openError': 'Не удалось открыть хранилище. Повторите попытку.', 'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.', 'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу', 'vaultSelection.createTitle': 'Создать новое хранилище', @@ -135,6 +135,18 @@ export default { 'workspaceTree.templateToolNoTabs': 'Не предоставляет вкладку Дела', 'workspaceTree.templateWillBeIncomplete': 'Дело будет создано без недоступных компонентов.', 'workspaceTree.templateIncompleteCreated': 'Дело «{name}» создано с недоступными компонентами.', + 'workspaceTree.templatesError': 'Не удалось загрузить шаблоны Дел. Повторите попытку.', + 'workspaceTree.loadError': 'Не удалось загрузить Дела. Повторите попытку.', + 'workspaceTree.selectError': 'Не удалось выбрать это Дело. Повторите попытку.', + 'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.', + 'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.', + 'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.', + 'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.', + 'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.', + 'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.', + 'pluginManager.disableError': 'Не удалось выключить плагин. Повторите попытку.', + 'pluginManager.settingsLoadError': 'Не удалось загрузить настройки плагина. Повторите попытку.', + 'pluginManager.settingsUnavailable': 'Настройки плагина недоступны.', 'command.openOverview': 'Открыть обзор', 'command.openFiles': 'Открыть файлы', 'command.openActivity': 'Открыть активность', @@ -146,6 +158,7 @@ export default { 'command.openPluginManager': 'Открыть менеджер плагинов', 'command.handled': 'Выполнено: {title}', 'command.result': '{title}: {status}', + 'command.failed': 'Не удалось выполнить команду «{title}». Повторите попытку.', 'command.statusHandled': 'выполнено', 'command.palette': 'Палитра команд', 'command.run': 'Выполнить команду', @@ -166,14 +179,14 @@ export default { 'workbench.noResource': 'Ресурс не открыт', 'bundle.noFrontend': 'У плагина нет frontend bundle', 'bundle.notFound': 'Плагин не найден', - 'bundle.loadFailed': 'Не удалось загрузить bundle: {error}', + 'bundle.loadFailed': 'Не удалось загрузить интерфейс плагина. Повторите попытку.', 'bundle.emptyContent': 'пустое содержимое', - 'bundle.executionError': 'Ошибка выполнения bundle: {error}', + 'bundle.executionError': 'Не удалось запустить интерфейс плагина. Повторите попытку.', 'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен', - 'bundle.componentMissing': 'Компонент «{component}» не найден в bundle. Доступны: {available}', - 'bundle.mountError': 'Ошибка подключения компонента: {error}', + 'bundle.componentMissing': 'Запрошенный интерфейс плагина недоступен.', + 'bundle.mountError': 'Не удалось открыть интерфейс плагина. Повторите попытку.', 'bundle.mountUnavailable': 'Контейнер для подключения недоступен', - 'bundle.unexpectedError': 'Непредвиденная ошибка: {error}', + 'bundle.unexpectedError': 'Не удалось открыть интерфейс плагина. Повторите попытку.', 'bundle.unknownError': 'Неизвестная ошибка', 'bundle.frontendEntry': 'Точка входа frontend', 'bundle.availableComponents': 'Доступные компоненты', diff --git a/frontend/src/lib/plugin-host/PluginBundleHost.svelte b/frontend/src/lib/plugin-host/PluginBundleHost.svelte index 1d5b993..071c96e 100644 --- a/frontend/src/lib/plugin-host/PluginBundleHost.svelte +++ b/frontend/src/lib/plugin-host/PluginBundleHost.svelte @@ -83,6 +83,11 @@ return { value: result, error: '' }; } + function reportError(key, fallback, details) { + console.warn('[PluginBundleHost] ' + key + ':', details); + return tr(key, undefined, fallback); + } + async function loadAndMount(pId, compId, nextPropsKey) { // If same plugin+component and already mounted, skip if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') { @@ -125,7 +130,7 @@ const content = assetResult.value; if (assetResult.error || !content) { loadState = 'error'; - errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') }); + errorText = reportError('bundle.loadFailed', 'Could not load the plugin interface. Please try again.', assetResult.error || tr('bundle.emptyContent')); return; } @@ -136,8 +141,7 @@ fn(); } catch (e) { loadState = 'error'; - errorText = tr('bundle.executionError', { error: e.message }); - console.error('[PluginBundleHost] bundle exec error:', e); + errorText = reportError('bundle.executionError', 'Could not start the plugin interface. Please try again.', e); return; } @@ -154,10 +158,7 @@ const comp = components[compId]; if (!comp || !comp.mount) { loadState = 'error'; - errorText = tr('bundle.componentMissing', { - component: compId, - available: Object.keys(components).join(', ') || tr('common.none'), - }); + errorText = tr('bundle.componentMissing', undefined, 'The requested plugin interface is unavailable.'); return; } @@ -177,8 +178,7 @@ errorText = ''; } catch (e) { loadState = 'error'; - errorText = tr('bundle.mountError', { error: e.message }); - console.error('[PluginBundleHost] mount error:', e); + errorText = reportError('bundle.mountError', 'Could not open the plugin interface. Please try again.', e); } } else { loadState = 'error'; @@ -186,8 +186,7 @@ } } catch (e) { loadState = 'error'; - errorText = tr('bundle.unexpectedError', { error: e.message || e }); - console.error('[PluginBundleHost] error:', e); + errorText = reportError('bundle.unexpectedError', 'Could not open the plugin interface. Please try again.', e); } } @@ -208,17 +207,18 @@

{tr('pluginView.error')}

-
+

{errorText || tr('bundle.unknownError')}

+
+ {tr('common.details')}

{tr('common.plugin')}: {currentPluginId || tr('common.unknown')}

{tr('common.component')}: {currentComponent || tr('common.unknown')}

-

{errorText || tr('bundle.unknownError')}

{#if pluginInfo}

{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}

{/if} {#if getComponentList().length > 0}

{tr('bundle.availableComponents')}: {getComponentList().join(', ')}

{/if} -
+
{:else} @@ -303,11 +303,21 @@ max-width: 400px; text-align: left; background: #16213e; - padding: 1rem; + padding: 0.5rem 0.75rem; border-radius: 6px; border: 1px solid #0f3460; } + .error-details[open] { + padding: 0.75rem 1rem; + } + + .error-details summary { + cursor: pointer; + color: #e0e0f0; + font-weight: 600; + } + .error-details p { margin: 0.3rem 0; } @@ -318,12 +328,8 @@ .error-message { color: #e94560; - font-family: monospace; - font-size: 0.8rem; - margin-top: 0.5rem !important; - padding: 0.5rem; - background: rgba(233, 69, 96, 0.1); - border-radius: 4px; + max-width: 400px; + margin: 0 0 0.75rem; } .error-meta { diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte index b3ed81e..27d01d7 100644 --- a/frontend/src/lib/plugin-manager/PluginManager.svelte +++ b/frontend/src/lib/plugin-manager/PluginManager.svelte @@ -53,6 +53,12 @@ }, 4000); } + function reportError(key, fallback, details) { + debug.log('[PluginManager] ' + key + ':', String(details)); + WriteFrontendLog('PluginManager', key + ': ' + String(details)).catch(() => {}); + return tr(key, undefined, fallback); + } + function notifyPluginsChanged() { window.dispatchEvent(new CustomEvent('verstak:plugins-changed')); } @@ -89,14 +95,14 @@ ReadPluginSettings(pluginId).then(result => { const unpacked = unpackBackendResult(result); if (unpacked.error) { - settingsError = unpacked.error; + settingsError = reportError('pluginManager.settingsLoadError', 'Could not load plugin settings. Please try again.', unpacked.error); settingsData = {}; return; } settingsData = unpacked.value || {}; }).catch(() => { settingsData = {}; }); } else { - settingsError = `Settings panel not found for plugin "${pluginId}". Check that the plugin is enabled and has settingsPanels in its manifest.`; + settingsError = tr('pluginManager.settingsUnavailable', undefined, 'Plugin settings are unavailable.'); } } @@ -127,7 +133,7 @@ } catch (e) { debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e)); WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e)); - error = 'GetPlugins: ' + String(e); + error = reportError('pluginManager.loadError', 'Could not load plugins. Please try again.', e); loading = false; return; } @@ -191,7 +197,7 @@ resultMsg = `Reloaded ${count} plugin(s). ${summary}`; } catch (e) { debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e)); - error = 'Reload: ' + String(e); + error = reportError('pluginManager.reloadError', 'Could not reload plugins. Please try again.', e); reloading = false; return; } @@ -211,7 +217,7 @@ if (err) { debug.log('[PluginManager] enablePlugin: ERROR:', err); actionFeedback = { ...actionFeedback, [pluginId]: null }; - error = 'Enable: ' + err; + error = reportError('pluginManager.enableError', 'Could not enable the plugin. Please try again.', err); return; } debug.log('[PluginManager] enablePlugin: success, reloading...'); @@ -232,7 +238,7 @@ if (err) { debug.log('[PluginManager] disablePlugin: ERROR:', err); actionFeedback = { ...actionFeedback, [pluginId]: null }; - error = 'Disable: ' + err; + error = reportError('pluginManager.disableError', 'Could not disable the plugin. Please try again.', err); return; } debug.log('[PluginManager] disablePlugin: success, reloading...'); diff --git a/frontend/src/lib/shell/CommandPalette.svelte b/frontend/src/lib/shell/CommandPalette.svelte index bb849fa..d63b51c 100644 --- a/frontend/src/lib/shell/CommandPalette.svelte +++ b/frontend/src/lib/shell/CommandPalette.svelte @@ -3,6 +3,7 @@ import * as App from '../../../wailsjs/go/api/App'; import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js'; import { i18n } from '../i18n/index.js'; + import { debug } from '../log/debug.js'; let open = false; let query = ''; @@ -194,7 +195,12 @@ closePalette(); setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') })); } catch (err) { - setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`); + const details = err?.message || String(err); + debug.log('[CommandPalette] command failed:', details); + App.WriteFrontendLog('CommandPalette', `command failed: ${details}`).catch(() => {}); + setStatus('error', tr('command.failed', { + title: command.title || command.id, + }, `Could not run ${command.title || command.id}. Please try again.`)); } } diff --git a/frontend/src/lib/shell/VaultSelection.svelte b/frontend/src/lib/shell/VaultSelection.svelte index 04af9a2..72b844a 100644 --- a/frontend/src/lib/shell/VaultSelection.svelte +++ b/frontend/src/lib/shell/VaultSelection.svelte @@ -18,6 +18,12 @@ void activeLocale; return i18n.t(key, params, fallback); })(locale); + + function reportError(key, fallback, details) { + console.warn('[VaultSelection] operation failed:', details); + return tr(key, undefined, fallback); + } + onDestroy(unsubscribeLocale); onMount(async () => { @@ -54,13 +60,13 @@ try { const createErr = await App.CreateVault(newVaultPath.trim()); if (createErr) { - error = tr('vaultSelection.createError', { error: createErr }); + error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', createErr); creating = false; return; } const openErr = await App.OpenVault(newVaultPath.trim()); if (openErr) { - error = tr('vaultSelection.openError', { error: openErr }); + error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr); creating = false; return; } @@ -70,7 +76,7 @@ } window.dispatchEvent(new CustomEvent('verstak:vault-opened')); } catch (e) { - error = String(e); + error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', e); creating = false; } } @@ -85,7 +91,7 @@ try { const openErr = await App.OpenVault(openVaultPath.trim()); if (openErr) { - error = tr('vaultSelection.openError', { error: openErr }); + error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr); opening = false; return; } @@ -95,7 +101,7 @@ } window.dispatchEvent(new CustomEvent('verstak:vault-opened')); } catch (e) { - error = String(e); + error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e); opening = false; } } @@ -106,7 +112,7 @@ try { const openErr = await App.OpenVault(path); if (openErr) { - error = tr('vaultSelection.openError', { error: openErr }); + error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr); opening = false; return; } @@ -116,7 +122,7 @@ } window.dispatchEvent(new CustomEvent('verstak:vault-opened')); } catch (e) { - error = String(e); + error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e); opening = false; } } diff --git a/frontend/src/lib/shell/WorkspaceTree.svelte b/frontend/src/lib/shell/WorkspaceTree.svelte index 75c3669..61f848c 100644 --- a/frontend/src/lib/shell/WorkspaceTree.svelte +++ b/frontend/src/lib/shell/WorkspaceTree.svelte @@ -35,6 +35,11 @@ return i18n.t(key, params, fallback); })(locale); + function reportError(key, fallback, details) { + console.warn('[WorkspaceTree] operation failed:', details); + return tr(key, undefined, fallback); + } + onMount(() => { unsubscribeLocale = i18n.subscribe((nextLocale) => { const changed = locale !== nextLocale; @@ -128,7 +133,7 @@ ]); const [list, err] = resultOrError(templates, []); if (err) { - createError = err; + createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err); workspaceTemplates = []; return; } @@ -154,7 +159,7 @@ selectedTemplateId = workspaceTemplates[0]?.id || ''; } } catch (error) { - createError = String(error); + createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error); workspaceTemplates = []; } finally { templatesLoading = false; @@ -188,7 +193,7 @@ try { const [list, err] = resultOrError(await App.ListWorkspaces(), []); if (err) { - localError = err; + localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err); workspaces = []; } else { workspaces = list || []; @@ -209,7 +214,7 @@ activeWorkspaceId.set(currentWorkspaceId); } } catch (e) { - localError = String(e); + localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e); } loading = false; } @@ -218,7 +223,7 @@ const id = wsName(workspace); const err = await App.SetCurrentWorkspace(id); if (err) { - localError = err; + localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err); return; } currentWorkspaceId = id; @@ -247,7 +252,7 @@ createError = ''; const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null); if (err) { - createError = err; + createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err); creating = false; return; } @@ -299,7 +304,7 @@ busyId = oldName; const err = await App.RenameWorkspace(oldName, newName); if (err) { - localError = err; + localError = reportError('workspaceTree.renameError', 'Could not rename the Deal. Please try again.', err); busyId = ''; return; } @@ -317,7 +322,7 @@ busyId = name; const [, err] = resultOrError(await App.TrashWorkspace(name), null); if (err) { - localError = err; + localError = reportError('workspaceTree.trashError', 'Could not move the Deal to trash. Please try again.', err); busyId = ''; return; } diff --git a/frontend/tests/no-technical-user-errors-test.mjs b/frontend/tests/no-technical-user-errors-test.mjs new file mode 100644 index 0000000..c196098 --- /dev/null +++ b/frontend/tests/no-technical-user-errors-test.mjs @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const files = [ + 'src/lib/shell/CommandPalette.svelte', + 'src/lib/plugin-manager/PluginManager.svelte', + 'src/lib/shell/VaultSelection.svelte', + 'src/lib/shell/WorkspaceTree.svelte', + 'src/lib/plugin-host/PluginBundleHost.svelte', +]; +const patterns = [ + /(?:^|\n)\s*(?:error|localError|createError)\s*=\s*(?:err|String\()/, + /(?:^|\n)\s*error\s*=\s*['"].*['"]\s*\+\s*(?:err|String\()/, + /tr\([^\n]*\{\s*error\s*:/, +]; +const violations = []; + +for (const relativePath of files) { + const source = fs.readFileSync(path.join(root, relativePath), 'utf8'); + for (const pattern of patterns) { + if (pattern.test(source)) violations.push(relativePath); + } +} + +if (violations.length) { + throw new Error(`Technical backend errors reach desktop UI in: ${[...new Set(violations)].join(', ')}`); +} + +const bundleHost = fs.readFileSync(path.join(root, 'src/lib/plugin-host/PluginBundleHost.svelte'), 'utf8'); +if (!/

\{errorText \|\| tr\('bundle\.unknownError'\)\}<\/p>\s*

/.test(bundleHost)) { + throw new Error('Plugin bundle errors must keep the user-facing message outside expandable technical details'); +} + +console.log('desktop UI does not interpolate raw backend errors');