From e412cfc45ab0a5318c3407fcdceb587e3fe8dc06 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 10 Jul 2026 21:32:48 +0800 Subject: [PATCH] feat: add workspace templates and tab visibility --- docs/PLUGIN_RUNTIME.md | 39 +++-- docs/WORKSPACE_TEMPLATES.md | 40 +++++ frontend/e2e/plugin-manager-layout.spec.js | 7 +- frontend/e2e/workspace-templates.spec.js | 98 +++++++++++ frontend/src/lib/shell/WorkspaceHost.svelte | 35 +++- frontend/src/lib/shell/WorkspaceTree.svelte | 141 +++++++++++++-- frontend/src/lib/test/wails-mock.js | 162 ++++++++++++++++- frontend/wailsjs/go/api/App.d.ts | 2 + frontend/wailsjs/go/api/App.js | 4 + frontend/wailsjs/go/models.ts | 25 +++ internal/api/app.go | 8 + internal/api/app_test.go | 19 ++ internal/core/workspace/manager.go | 184 +++++++++++++++++--- internal/core/workspace/manager_test.go | 42 +++++ 14 files changed, 739 insertions(+), 67 deletions(-) create mode 100644 docs/WORKSPACE_TEMPLATES.md create mode 100644 frontend/e2e/workspace-templates.spec.js diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index f13cf89..e8fe6bf 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -872,8 +872,8 @@ Workspace existence/list хранится только в filesystem: `.verstak` может хранить только metadata, которая не заменяет filesystem: - UI state: selected workspace, expanded folders, sort/pin state, preferences. -- Semantic snapshot: applied template snapshot, enabled feature areas, folder - conventions. +- Semantic snapshot: applied template snapshot, enabled feature areas, exact + `workspaceTools`, and folder conventions. Template snapshot копируется в metadata при создании workspace. Workspace identity при этом остаётся именем top-level folder; `metadata.workspaceName` @@ -885,22 +885,39 @@ identity при этом остаётся именем top-level folder; `metada { "workspaceName": "Project", "createdFromTemplate": { - "templateId": "client-project", - "templateName": "Client Project", + "templateId": "project", + "templateName": "Project", "templateVersion": 1, - "appliedAt": "2026-06-19T12:00:00Z" + "appliedAt": "2026-06-19T12:00:00Z", + "workspaceTools": [ + "verstak.notes", + "verstak.files", + "verstak.todo", + "verstak.journal", + "verstak.activity", + "verstak.browser-inbox" + ] }, "features": { "notes": true, "files": true, - "secrets": true, - "activity": false + "todo": true, + "journal": true, + "activity": true, + "browser-inbox": true }, "folders": { "notes": "Notes", - "files": "Files", - "secrets": "Secrets" - } + "files": "Files" + }, + "workspaceTools": [ + "verstak.notes", + "verstak.files", + "verstak.todo", + "verstak.journal", + "verstak.activity", + "verstak.browser-inbox" + ] } ``` @@ -912,6 +929,8 @@ update/migration может быть только явной future feature. Е ### API - `ListWorkspaces()` — список top-level physical folders. +- `ListWorkspaceTemplates()` — selectable built-in templates с presentation + metadata и `workspaceTools`. - `CreateWorkspace(name, templateId?)` — создать `//`, применить template один раз, сохранить snapshot metadata. - `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder diff --git a/docs/WORKSPACE_TEMPLATES.md b/docs/WORKSPACE_TEMPLATES.md new file mode 100644 index 0000000..126ec31 --- /dev/null +++ b/docs/WORKSPACE_TEMPLATES.md @@ -0,0 +1,40 @@ +# Workspace Templates + +Workspace templates choose which dynamic plugin workspace tabs are visible when a +new top-level workspace is created. They do not enable or disable plugins for the +whole vault and do not affect global sidebar tools. + +## Built-in templates + +| Template | Workspace tabs | +| --- | --- | +| General | Notes, Files, Journal, Activity, Browser Inbox | +| Project | Notes, Files, Todos, Journal, Activity, Browser Inbox | +| Writing | Notes, Files, Journal | +| Admin | Notes, Files, Secrets, Todos, Journal | +| Minimal | Notes, Files | + +The create-workspace modal displays the selected template description and its +included plugin tabs before the folder is created. + +## Metadata and compatibility + +Creation stores a template snapshot in `.verstak/workspaces/` metadata. The +snapshot contains the template id, name, version, applied time, and an ordered +`workspaceTools` list of plugin IDs. Existing workspace metadata without +`workspaceTools` remains compatible: its workspace continues to show all globally +enabled workspace plugins rather than unexpectedly hiding tabs. + +Templates are applied once. Editing the built-in catalog or creating another +workspace with a different template never changes an existing workspace snapshot. +There is no template editor or post-creation template switcher yet. + +## Global tools and unavailable plugins + +Template visibility applies only to `workspaceItems` in the selected workspace. +Global views and sidebar items, such as global Todos, Browser Inbox, and Trash, +remain available according to the normal plugin enablement state. + +The workspace host intersects the snapshot with dynamically discovered, globally +enabled plugin contributions. If a template references a plugin that is missing or +disabled, that tab is simply absent; the other template tabs remain usable. diff --git a/frontend/e2e/plugin-manager-layout.spec.js b/frontend/e2e/plugin-manager-layout.spec.js index 2174863..3ce1d31 100644 --- a/frontend/e2e/plugin-manager-layout.spec.js +++ b/frontend/e2e/plugin-manager-layout.spec.js @@ -78,7 +78,7 @@ test.describe('E: Plugin Manager layout', () => { const health = page.locator('[data-plugin-manager-summary="health"]'); await expect(health).toBeVisible(); - await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('11'); + await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('12'); await expect(health.locator('[data-plugin-status-summary="failed"]')).toContainText('0'); await expect(health.locator('[data-plugin-status-summary="disabled"]')).toContainText('0'); @@ -119,8 +119,9 @@ 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('.wt-create input').fill('ClientA'); - await page.locator('.wt-btn-primary', { hasText: 'Create' }).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 expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible(); diff --git a/frontend/e2e/workspace-templates.spec.js b/frontend/e2e/workspace-templates.spec.js new file mode 100644 index 0000000..7b15469 --- /dev/null +++ b/frontend/e2e/workspace-templates.spec.js @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test'; +import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js'; + +test.describe('Workspace templates', () => { + let consoleCollector; + + test.beforeEach(async ({ page }) => { + consoleCollector = setupConsoleCollector(page); + await resetMockState(page); + await page.goto('/'); + await waitForAppReady(page); + }); + + test.afterEach(async () => { + consoleCollector.assertNoErrors(); + }); + + async function openCreateModal(page) { + await page.locator('button[title="New workspace"]').click(); + const modal = page.locator('[data-workspace-create-modal]'); + await expect(modal).toBeVisible(); + return modal; + } + + test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => { + const modal = await openCreateModal(page); + await expect(modal.locator('[data-workspace-template]')).toHaveValue('default'); + await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes'); + await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser Inbox'); + + await modal.getByRole('button', { name: 'Create workspace' }).click(); + await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required'); + + await modal.locator('[data-workspace-name]').fill('bad/name'); + await modal.getByRole('button', { name: 'Create workspace' }).click(); + await expect(modal.locator('[data-workspace-create-error]')).toContainText('invalid-workspace-name'); + + await modal.locator('[data-workspace-name]').fill('ProjectPlan'); + await modal.locator('[data-workspace-template]').selectOption('project'); + await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning'); + await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos'); + await modal.getByRole('button', { name: 'Create workspace' }).click(); + + await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible(); + await expect.poll(async () => page.evaluate(async () => { + const result = await window.go.api.App.GetWorkspaceMetadata('ProjectPlan'); + const metadata = Array.isArray(result) ? result[0] : result; + return { + templateId: metadata.createdFromTemplate?.templateId, + tools: metadata.workspaceTools, + }; + })).toEqual({ + templateId: 'project', + tools: ['verstak.notes', 'verstak.files', 'verstak.todo', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'], + }); + + await expect(page.getByRole('tab', { name: 'Todos' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Journal' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0); + }); + + test('Minimal keeps global tools available while limiting workspace tabs', async ({ page }) => { + const modal = await openCreateModal(page); + await modal.locator('[data-workspace-name]').fill('MinimalSpace'); + await modal.locator('[data-workspace-template]').selectOption('minimal'); + await modal.getByRole('button', { name: 'Create workspace' }).click(); + + await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Todos' })).toHaveCount(0); + await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0); + await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0); + await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible(); + await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible(); + }); + + 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'); + await modal.locator('[data-workspace-template]').selectOption('admin'); + await modal.getByRole('button', { name: 'Create workspace' }).click(); + await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible(); + + await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false)); + modal = await openCreateModal(page); + await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo'); + await modal.locator('[data-workspace-template]').selectOption('project'); + await modal.getByRole('button', { name: 'Create workspace' }).click(); + + await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Todos' })).toHaveCount(0); + + await page.locator('.wt-label').filter({ hasText: 'AdminSpace' }).click(); + await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible(); + }); +}); diff --git a/frontend/src/lib/shell/WorkspaceHost.svelte b/frontend/src/lib/shell/WorkspaceHost.svelte index 7fdf055..9407bb0 100644 --- a/frontend/src/lib/shell/WorkspaceHost.svelte +++ b/frontend/src/lib/shell/WorkspaceHost.svelte @@ -11,7 +11,10 @@ let contributions = {}; let plugins = []; + let discoveredWorkspaceTools = []; let workspaceTools = []; + let workspaceMetadata = null; + let metadataWorkspaceRoot = ''; let toolsLoaded = false; let requestedToolKind = ''; let requestedToolRequest = null; @@ -27,6 +30,7 @@ ['activity', 40], ['browser', 50], ['inbox', 50], + ['secrets', 60], ['search', 90], ]); @@ -34,6 +38,12 @@ $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceType = selectedWorkspace?.type || 'workspace'; + $: if (workspaceRootPath !== metadataWorkspaceRoot) { + metadataWorkspaceRoot = workspaceRootPath; + workspaceMetadata = null; + if (workspaceRootPath) loadWorkspaceMetadata(workspaceRootPath); + } + $: workspaceTools = sortWorkspaceTools(filterWorkspaceTools(discoveredWorkspaceTools, workspaceMetadata)); $: if (workspaceRootPath !== requestedWorkspaceRoot) { requestedWorkspaceRoot = workspaceRootPath; requestedToolRequest = null; @@ -83,6 +93,29 @@ }); } + function filterWorkspaceTools(tools, metadata) { + if (!Array.isArray(metadata?.workspaceTools)) return tools; + const allowedPluginIds = new Set(metadata.workspaceTools); + return tools.filter(tool => allowedPluginIds.has(tool.pluginId)); + } + + function resultOrError(response, fallbackValue) { + if (Array.isArray(response) && typeof response[1] === 'string') { + return [response[0] || fallbackValue, response[1] || '']; + } + return typeof response === 'string' ? [fallbackValue, response] : [response || fallbackValue, '']; + } + + async function loadWorkspaceMetadata(rootPath) { + try { + const [metadata, err] = resultOrError(await App.GetWorkspaceMetadata(rootPath), null); + if (rootPath !== workspaceRootPath) return; + workspaceMetadata = err ? null : metadata; + } catch (_) { + if (rootPath === workspaceRootPath) workspaceMetadata = null; + } + } + function selectTool(tool, toolRequest = null) { activeToolKey = toolKey(tool); activeToolRequest = toolRequest; @@ -137,7 +170,7 @@ plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id) ); - workspaceTools = sortWorkspaceTools((contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId))); + discoveredWorkspaceTools = (contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId)); } catch (e) { console.error('[WorkspaceHost] loadTools error:', e); } finally { diff --git a/frontend/src/lib/shell/WorkspaceTree.svelte b/frontend/src/lib/shell/WorkspaceTree.svelte index 30dfd74..c5c49f8 100644 --- a/frontend/src/lib/shell/WorkspaceTree.svelte +++ b/frontend/src/lib/shell/WorkspaceTree.svelte @@ -15,6 +15,11 @@ let currentWorkspaceId = ''; let showCreate = false; let newWorkspaceName = ''; + let workspaceTemplates = []; + let templatePluginNames = {}; + let selectedTemplateId = 'default'; + let createError = ''; + let templatesLoading = false; let creating = false; let renamingId = ''; let renameValue = ''; @@ -22,6 +27,7 @@ onMount(() => { loadWorkspaces(); + loadWorkspaceTemplates(); window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged); }); @@ -35,9 +41,49 @@ } function resultOrError(response, fallbackValue) { + if (Array.isArray(response) && typeof response[1] === 'string') { + return [response[0] || fallbackValue, response[1] || '']; + } return typeof response === 'string' ? [fallbackValue, response] : [response, '']; } + $: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null; + + function toolLabel(pluginId) { + return templatePluginNames[pluginId] || String(pluginId || '').replace(/^verstak\./, ''); + } + + async function loadWorkspaceTemplates() { + templatesLoading = true; + try { + const [templates, plugins] = await Promise.all([ + App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [], + App.GetPlugins ? App.GetPlugins() : [], + ]); + const [list, err] = resultOrError(templates, []); + if (err) { + createError = err; + workspaceTemplates = []; + return; + } + workspaceTemplates = Array.isArray(list) ? list : []; + templatePluginNames = (Array.isArray(plugins) ? plugins : []).reduce((names, plugin) => { + const id = plugin?.manifest?.id; + const name = plugin?.manifest?.name; + if (id && name) names[id] = name; + return names; + }, {}); + if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) { + selectedTemplateId = workspaceTemplates[0]?.id || ''; + } + } catch (error) { + createError = String(error); + workspaceTemplates = []; + } finally { + templatesLoading = false; + } + } + function wsName(workspace) { return String(workspace?.name || workspace?.rootPath || ''); } @@ -107,12 +153,19 @@ async function doCreate() { const name = newWorkspaceName.trim(); - if (!name) return; + if (!name) { + createError = 'Name is required'; + return; + } + if (!selectedTemplate) { + createError = 'Choose a workspace template'; + return; + } creating = true; - localError = ''; - const [, err] = resultOrError(await App.CreateWorkspace(name, 'default'), null); + createError = ''; + const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null); if (err) { - localError = err; + createError = err; creating = false; return; } @@ -124,6 +177,20 @@ if (created) await selectWorkspace(created); } + function openCreateDialog() { + showCreate = true; + newWorkspaceName = ''; + createError = ''; + if (!workspaceTemplates.length && !templatesLoading) loadWorkspaceTemplates(); + } + + function closeCreateDialog() { + if (creating) return; + showCreate = false; + newWorkspaceName = ''; + createError = ''; + } + function startRename(workspace) { renamingId = wsName(workspace); renameValue = renamingId; @@ -180,7 +247,7 @@
Workspaces - +
{#if loading} @@ -222,15 +289,43 @@
{#if showCreate} -
-
- New workspace - -
- -
- - + {/if} @@ -261,11 +356,19 @@ .wt-icon-btn.danger:hover:not(:disabled) { color: var(--vt-color-danger); background: var(--vt-color-danger-muted); border-color: rgba(233,69,96,0.35); } .wt-rename { flex: 1; min-width: 0; background: #0f1424; border: 1px solid var(--vt-color-border-strong); color: var(--vt-color-text-primary); padding: 0.2rem 0.35rem; border-radius: var(--vt-radius-sm); font-size: 0.78rem; } .wt-rename:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); } - .wt-create { position: absolute; bottom: 0; left: 0; right: 0; background: var(--vt-color-surface-muted); border-top: 1px solid var(--vt-color-border); padding: 0.6rem; z-index: 10; } - .wt-create-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; color: var(--vt-color-text-muted); font-size: 0.7rem; text-transform: uppercase; } - .wt-create input { width: 100%; background: #0f1424; border: 1px solid var(--vt-color-border-strong); color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; border-radius: var(--vt-radius-sm); font-size: 0.8rem; margin-bottom: 0.4rem; box-sizing: border-box; } - .wt-create input:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); } - .wt-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; } + .workspace-create-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(4, 8, 18, 0.7); } + .workspace-create-modal { width: min(34rem, 100%); display: grid; gap: 0.85rem; padding: 1rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-lg); background: var(--vt-color-surface); box-shadow: 0 18px 44px rgba(0, 0, 0, 0.38); } + .workspace-create-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } + .workspace-create-header h2 { margin: 0; font-size: 1rem; } + .workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; } + .workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; } + .workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); } + .workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); } + .workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; } + .workspace-template-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; } + .workspace-template-tools span { min-height: 1.35rem; display: inline-flex; align-items: center; padding: 0 0.35rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; } + .workspace-create-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; } + .workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; } .wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; } .wt-btn-primary:hover:not(:disabled) { background: #3dbb92; } .wt-btn-primary:disabled, .wt-btn:disabled, .wt-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index 281c24e..09960c5 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -255,6 +255,7 @@ error: '' }, 'verstak.todo': makeTodoPluginState(), + 'verstak.secrets': makeSecretsPluginState(), 'verstak.search': { status: 'loaded', enabled: true, @@ -288,6 +289,8 @@ vaultPluginState.desiredPlugins.push({ id: 'verstak.trash', version: '0.1.0', source: 'official' }); vaultPluginState.enabledPlugins.push('verstak.todo'); vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' }); + vaultPluginState.enabledPlugins.push('verstak.secrets'); + vaultPluginState.desiredPlugins.push({ id: 'verstak.secrets', version: '0.1.0', source: 'official' }); var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] }; var workbenchPreferences = {}; var openedResources = []; @@ -301,6 +304,7 @@ var trashPayloads = {}; window.__wailsMockExternalOpens = []; var workspaceTree = makeDefaultWorkspaceTree(); + var workspaceMetadata = {}; var reloadResponseMode = 'tuple'; var syncState = makeDefaultSyncState(); @@ -334,6 +338,93 @@ return { id: name, parentId: '', type: 'space', title: name, name: name, rootPath: name, status: 'active', order: order }; } + function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); + } + + function builtInWorkspaceTemplates() { + return [ + { + id: 'default', + name: 'General', + description: 'Everyday workspace with notes, files, journal, activity, and browser captures.', + version: 2, + workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'], + folders: ['Notes', 'Files'], + features: { files: true, notes: true, activity: true, journal: true, 'browser-inbox': true }, + }, + { + id: 'project', + name: 'Project', + description: 'Project planning with todos, journal, activity, and browser captures.', + version: 1, + workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.todo', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'], + folders: ['Notes', 'Files'], + features: { files: true, notes: true, todo: true, journal: true, activity: true, 'browser-inbox': true }, + }, + { + id: 'writing', + name: 'Writing', + description: 'Focused notes, files, and journal workspace for documentation and writing.', + version: 1, + workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal'], + folders: ['Notes', 'Files'], + features: { files: true, notes: true, journal: true }, + }, + { + id: 'admin', + name: 'Admin', + description: 'Infrastructure workspace with secrets, todos, and journal.', + version: 1, + workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.secrets', 'verstak.todo', 'verstak.journal'], + folders: ['Notes', 'Files', 'Secrets'], + features: { files: true, notes: true, secrets: true, todo: true, journal: true }, + }, + { + id: 'minimal', + name: 'Minimal', + description: 'Only notes and files for a lightweight workspace.', + version: 1, + workspaceTools: ['verstak.notes', 'verstak.files'], + folders: ['Notes', 'Files'], + features: { files: true, notes: true }, + }, + ]; + } + + function workspaceTemplateByID(templateID) { + var id = String(templateID || 'default'); + return builtInWorkspaceTemplates().find(function (template) { return template.id === id; }) || null; + } + + function metadataForTemplate(name, template) { + var now = new Date().toISOString(); + var folders = { notes: 'Notes', files: 'Files' }; + if (template.features.secrets) folders.secrets = 'Secrets'; + return { + workspaceName: name, + createdFromTemplate: { + templateId: template.id, + templateName: template.name, + templateVersion: template.version, + appliedAt: now, + workspaceTools: template.workspaceTools.slice(), + }, + features: Object.assign({}, template.features), + folders: folders, + workspaceTools: template.workspaceTools.slice(), + updatedAt: now, + }; + } + + function genericWorkspaceMetadata(name) { + return { + workspaceName: name, + features: { files: true }, + folders: { notes: 'Notes', files: 'Files' }, + }; + } + function makeDefaultVaultFiles() { return { '': { type: 'folder', modifiedAt: new Date().toISOString() }, @@ -405,6 +496,31 @@ }; } + function makeSecretsPluginState() { + return { + status: 'loaded', + enabled: true, + manifest: { + schemaVersion: 1, + id: 'verstak.secrets', + name: 'Secrets', + version: '0.1.0', + apiVersion: '0.1.0', + description: 'Encrypted global and workspace-scoped secret manager.', + source: 'official', + icon: 'key-round', + provides: ['secret-store', 'secrets.read-ui', 'secrets.write-ui'], + permissions: ['secrets.read', 'secrets.write', 'ui.register'], + frontend: { entry: 'frontend/dist/index.js' }, + contributes: { + workspaceItems: [{ id: 'verstak.secrets.workspace', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }] + } + }, + rootPath: '/tmp/verstak-test/plugins/secrets', + error: '' + }; + } + function makeDefaultSyncState() { return { configured: false, @@ -3281,6 +3397,9 @@ if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') { return Promise.resolve(todoBundle()); } + if (pluginId === 'verstak.secrets' && assetPath === 'frontend/dist/index.js') { + return Promise.resolve(simplePluginBundle('verstak.secrets', 'SecretsView', 'secrets-root', 'Secrets')); + } if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') { return Promise.resolve(searchPluginBundle()); } @@ -3507,15 +3626,30 @@ ListWorkspaces: function () { return Promise.resolve(listWorkspacesFromTree()); }, - CreateWorkspace: function (name) { + ListWorkspaceTemplates: function () { + return Promise.resolve(builtInWorkspaceTemplates().map(function (template) { + return { + id: template.id, + name: template.name, + description: template.description, + version: template.version, + workspaceTools: template.workspaceTools.slice() + }; + })); + }, + CreateWorkspace: function (name, templateID) { var norm = normalizeVaultPath(name, false); if (norm.error || norm.path !== String(name || '').trim() || norm.path.indexOf('/') !== -1) { return Promise.resolve(norm.error || 'invalid-workspace-name'); } if (vaultFiles[norm.path]) return Promise.resolve('conflict: ' + norm.path); + var template = workspaceTemplateByID(templateID); + if (!template) return Promise.resolve('template-not-found: ' + String(templateID || '')); vaultFiles[norm.path] = { type: 'folder', modifiedAt: new Date().toISOString() }; - vaultFiles[norm.path + '/Notes'] = { type: 'folder', modifiedAt: new Date().toISOString() }; - vaultFiles[norm.path + '/Notes/Overview.md'] = { type: 'file', content: '# Overview\n', modifiedAt: new Date().toISOString() }; + template.folders.forEach(function (folder) { + vaultFiles[norm.path + '/' + folder] = { type: 'folder', modifiedAt: new Date().toISOString() }; + }); + workspaceMetadata[norm.path] = metadataForTemplate(norm.path, template); workspaceTree.nodes.push(makeWorkspaceNode(norm.path, workspaceTree.nodes.length + 1)); return Promise.resolve({ name: norm.path, rootPath: norm.path }); }, @@ -3537,6 +3671,10 @@ if (n.id !== oldNorm.path) return n; return makeWorkspaceNode(newNorm.path, n.order); }); + if (workspaceMetadata[oldNorm.path]) { + workspaceMetadata[newNorm.path] = Object.assign({}, workspaceMetadata[oldNorm.path], { workspaceName: newNorm.path }); + delete workspaceMetadata[oldNorm.path]; + } if (workspaceTree.currentNodeId === oldNorm.path) workspaceTree.currentNodeId = newNorm.path; return Promise.resolve(''); }, @@ -3548,6 +3686,7 @@ return path === norm.path || path.indexOf(norm.path + '/') === 0; }).forEach(function (path) { delete vaultFiles[path]; }); workspaceTree.nodes = workspaceTree.nodes.filter(function (n) { return n.id !== norm.path; }); + delete workspaceMetadata[norm.path]; if (workspaceTree.currentNodeId === norm.path) workspaceTree.currentNodeId = workspaceTree.nodes[0] ? workspaceTree.nodes[0].id : ''; return Promise.resolve({ originalPath: norm.path, trashPath: '.verstak/trash/workspaces/mock/' + norm.path, trashId: 'mock', deletedAt: new Date().toISOString() }); }, @@ -3555,14 +3694,15 @@ var norm = normalizeVaultPath(name, false); if (norm.error) return Promise.resolve(norm.error); if (!vaultFiles[norm.path]) return Promise.resolve('not-found: ' + norm.path); - return Promise.resolve({ - workspaceName: norm.path, - features: { files: true }, - folders: { notes: 'Notes', files: 'Files' } - }); + return Promise.resolve(cloneJson(workspaceMetadata[norm.path] || genericWorkspaceMetadata(norm.path))); }, UpdateWorkspaceMetadata: function (name, patch) { - return Promise.resolve(Object.assign({ workspaceName: name, features: { files: true }, folders: { notes: 'Notes', files: 'Files' } }, patch || {})); + var norm = normalizeVaultPath(name, false); + if (norm.error) return Promise.resolve(norm.error); + if (!vaultFiles[norm.path]) return Promise.resolve('not-found: ' + norm.path); + var next = Object.assign({}, workspaceMetadata[norm.path] || genericWorkspaceMetadata(norm.path), patch || {}, { workspaceName: norm.path, updatedAt: new Date().toISOString() }); + workspaceMetadata[norm.path] = next; + return Promise.resolve(cloneJson(next)); }, GetCurrentWorkspace: function () { var found = workspaceTree.nodes.find(function (n) { return n.id === workspaceTree.currentNodeId; }); @@ -3891,6 +4031,7 @@ error: '' }, 'verstak.todo': makeTodoPluginState(), + 'verstak.secrets': makeSecretsPluginState(), 'verstak.search': { status: 'loaded', enabled: true, @@ -3923,6 +4064,8 @@ vaultPluginState.desiredPlugins.push({ id: 'verstak.trash', version: '0.1.0', source: 'official' }); vaultPluginState.enabledPlugins.push('verstak.todo'); vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' }); + vaultPluginState.enabledPlugins.push('verstak.secrets'); + vaultPluginState.desiredPlugins.push({ id: 'verstak.secrets', version: '0.1.0', source: 'official' }); appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] }; workbenchPreferences = {}; openedResources = []; @@ -3933,6 +4076,7 @@ trashPayloads = {}; window.__wailsMockExternalOpens = []; workspaceTree = makeDefaultWorkspaceTree(); + workspaceMetadata = {}; reloadResponseMode = 'tuple'; syncState = makeDefaultSyncState(); }, diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 6090e2f..d1784ba 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -72,6 +72,8 @@ export function ListVaultTrash(arg1:string):Promise|stri export function ListWorkspaces():Promise|string>; +export function ListWorkspaceTemplates():Promise|string>; + export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise; export function MoveWorkspaceNode(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 4bdea3b..74bfd4d 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -130,6 +130,10 @@ export function ListWorkspaces() { return window['go']['api']['App']['ListWorkspaces'](); } +export function ListWorkspaceTemplates() { + return window['go']['api']['App']['ListWorkspaceTemplates'](); +} + export function MoveVaultPath(arg1, arg2, arg3, arg4) { return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 07c6aa6..f8d5c94 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1213,12 +1213,34 @@ export namespace workbench { } export namespace workspace { + + export class WorkspaceTemplate { + id: string; + name: string; + description: string; + version: number; + workspaceTools: string[]; + static createFrom(source: any = {}) { + return new WorkspaceTemplate(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.description = source["description"]; + this.version = source["version"]; + this.workspaceTools = source["workspaceTools"]; + } + } + export class TemplateSnapshot { templateId: string; templateName: string; templateVersion: number; appliedAt: string; + workspaceTools?: string[]; static createFrom(source: any = {}) { return new TemplateSnapshot(source); @@ -1230,6 +1252,7 @@ export namespace workspace { this.templateName = source["templateName"]; this.templateVersion = source["templateVersion"]; this.appliedAt = source["appliedAt"]; + this.workspaceTools = source["workspaceTools"]; } } export class Metadata { @@ -1237,6 +1260,7 @@ export namespace workspace { createdFromTemplate?: TemplateSnapshot; features?: Record; folders?: Record; + workspaceTools?: string[]; updatedAt?: string; static createFrom(source: any = {}) { @@ -1249,6 +1273,7 @@ export namespace workspace { this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot); this.features = source["features"]; this.folders = source["folders"]; + this.workspaceTools = source["workspaceTools"]; this.updatedAt = source["updatedAt"]; } diff --git a/internal/api/app.go b/internal/api/app.go index 7f3f59b..e92669e 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -1735,6 +1735,14 @@ func (a *App) ListWorkspaces() ([]workspace.Workspace, string) { return workspaces, "" } +// ListWorkspaceTemplates returns selectable built-in workspace templates. +func (a *App) ListWorkspaceTemplates() ([]workspace.WorkspaceTemplate, string) { + if a.workspace == nil { + return nil, "workspace not initialized" + } + return a.workspace.ListWorkspaceTemplates(), "" +} + // CreateWorkspace creates a top-level physical workspace folder. func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) { if a.workspace == nil { diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 43bf079..99f247b 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -1448,6 +1448,25 @@ func TestWorkspaceAPIUsesTopLevelFoldersAndMetadataSnapshot(t *testing.T) { } } +func TestWorkspaceAPIListsSelectableTemplates(t *testing.T) { + app, vaultDir := newFilesTestApp(t, []string{"files.read"}) + app.workspace = workspace.NewManager(vaultDir) + if err := app.workspace.Load(); err != nil { + t.Fatalf("workspace Load: %v", err) + } + + templates, errStr := app.ListWorkspaceTemplates() + if errStr != "" { + t.Fatalf("ListWorkspaceTemplates: %s", errStr) + } + if len(templates) != 5 { + t.Fatalf("templates = %+v, want 5 selectable templates", templates) + } + if templates[0].ID != "default" || templates[1].ID != "project" || templates[4].ID != "minimal" { + t.Fatalf("template order = %+v", templates) + } +} + func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) { app, vaultDir := newFilesTestApp(t, []string{"files.read"}) app.workspace = workspace.NewManager(vaultDir) diff --git a/internal/core/workspace/manager.go b/internal/core/workspace/manager.go index c5b7d5c..c29c7a4 100644 --- a/internal/core/workspace/manager.go +++ b/internal/core/workspace/manager.go @@ -46,10 +46,21 @@ type Workspace struct { // TemplateSnapshot is copied into workspace metadata when a template is applied. type TemplateSnapshot struct { - TemplateID string `json:"templateId"` - TemplateName string `json:"templateName"` - TemplateVersion int `json:"templateVersion"` - AppliedAt string `json:"appliedAt"` + TemplateID string `json:"templateId"` + TemplateName string `json:"templateName"` + TemplateVersion int `json:"templateVersion"` + AppliedAt string `json:"appliedAt"` + WorkspaceTools []string `json:"workspaceTools,omitempty"` +} + +// WorkspaceTemplate describes a selectable built-in template without exposing +// its filesystem implementation details. +type WorkspaceTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Version int `json:"version"` + WorkspaceTools []string `json:"workspaceTools"` } // Metadata stores semantic workspace metadata that is not the source of truth @@ -59,6 +70,7 @@ type Metadata struct { CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"` Features map[string]bool `json:"features,omitempty"` Folders map[string]string `json:"folders,omitempty"` + WorkspaceTools []string `json:"workspaceTools,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` } @@ -102,35 +114,127 @@ type WorkspaceTree struct { } type templateDefinition struct { - ID string - Name string - Version int - Features map[string]bool - Folders map[string]string - Files map[string]string + ID string + Name string + Description string + Version int + Features map[string]bool + Folders map[string]string + Files map[string]string + WorkspaceTools []string + Selectable bool + Order int } var builtInTemplates = map[string]templateDefinition{ "default": { - ID: "default", - Name: "Default Workspace", - Version: 1, + ID: "default", + Name: "General", + Description: "Everyday workspace with notes, files, journal, activity, and browser captures.", + Version: 2, Features: map[string]bool{ - "files": true, - "notes": true, - "secrets": false, - "activity": false, + "files": true, + "notes": true, + "secrets": false, + "activity": true, + "journal": true, + "browser-inbox": true, }, Folders: map[string]string{ "notes": "Notes", "files": "Files", }, - Files: map[string]string{}, + Files: map[string]string{}, + WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.journal", "verstak.activity", "verstak.browser-inbox"}, + Selectable: true, + Order: 10, + }, + "project": { + ID: "project", + Name: "Project", + Description: "Project planning with todos, journal, activity, and browser captures.", + Version: 1, + Features: map[string]bool{ + "files": true, + "notes": true, + "todo": true, + "journal": true, + "activity": true, + "browser-inbox": true, + }, + Folders: map[string]string{ + "notes": "Notes", + "files": "Files", + }, + Files: map[string]string{}, + WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.todo", "verstak.journal", "verstak.activity", "verstak.browser-inbox"}, + Selectable: true, + Order: 20, + }, + "writing": { + ID: "writing", + Name: "Writing", + Description: "Focused notes, files, and journal workspace for documentation and writing.", + Version: 1, + Features: map[string]bool{ + "files": true, + "notes": true, + "journal": true, + }, + Folders: map[string]string{ + "notes": "Notes", + "files": "Files", + }, + Files: map[string]string{}, + WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.journal"}, + Selectable: true, + Order: 30, + }, + "admin": { + ID: "admin", + Name: "Admin", + Description: "Infrastructure workspace with secrets, todos, and journal.", + Version: 1, + Features: map[string]bool{ + "files": true, + "notes": true, + "secrets": true, + "todo": true, + "journal": true, + }, + Folders: map[string]string{ + "notes": "Notes", + "files": "Files", + "secrets": "Secrets", + }, + Files: map[string]string{}, + WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.secrets", "verstak.todo", "verstak.journal"}, + Selectable: true, + Order: 40, + }, + "minimal": { + ID: "minimal", + Name: "Minimal", + Description: "Only notes and files for a lightweight workspace.", + Version: 1, + Features: map[string]bool{ + "files": true, + "notes": true, + }, + Folders: map[string]string{ + "notes": "Notes", + "files": "Files", + }, + Files: map[string]string{}, + WorkspaceTools: []string{"verstak.notes", "verstak.files"}, + Selectable: true, + Order: 50, }, "client-project": { - ID: "client-project", - Name: "Client Project", - Version: 1, + ID: "client-project", + Name: "Client Project", + Description: "Legacy client project template retained for existing integrations.", + Version: 1, Features: map[string]bool{ "files": true, "notes": true, @@ -142,7 +246,10 @@ var builtInTemplates = map[string]templateDefinition{ "files": "Files", "secrets": "Secrets", }, - Files: map[string]string{}, + Files: map[string]string{}, + WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.secrets"}, + Selectable: false, + Order: 0, }, } @@ -243,10 +350,12 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) { TemplateName: template.Name, TemplateVersion: template.Version, AppliedAt: now, + WorkspaceTools: cloneStringSlice(template.WorkspaceTools), }, - Features: cloneBoolMap(template.Features), - Folders: cloneStringMap(template.Folders), - UpdatedAt: now, + Features: cloneBoolMap(template.Features), + Folders: cloneStringMap(template.Folders), + WorkspaceTools: cloneStringSlice(template.WorkspaceTools), + UpdatedAt: now, } if err := m.writeMetadata(name, meta); err != nil { return Workspace{}, err @@ -256,6 +365,27 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) { return Workspace{Name: name, RootPath: name}, nil } +// ListWorkspaceTemplates returns selectable built-ins in their presentation order. +func (m *Manager) ListWorkspaceTemplates() []WorkspaceTemplate { + templates := make([]WorkspaceTemplate, 0, len(builtInTemplates)) + for _, template := range builtInTemplates { + if !template.Selectable { + continue + } + templates = append(templates, WorkspaceTemplate{ + ID: template.ID, + Name: template.Name, + Description: template.Description, + Version: template.Version, + WorkspaceTools: cloneStringSlice(template.WorkspaceTools), + }) + } + sort.SliceStable(templates, func(i, j int) bool { + return builtInTemplates[templates[i].ID].Order < builtInTemplates[templates[j].ID].Order + }) + return templates +} + // RenameWorkspace physically renames a top-level workspace folder and metadata key. func (m *Manager) RenameWorkspace(oldName, newName string) error { oldName = strings.TrimSpace(oldName) @@ -748,6 +878,10 @@ func cloneStringMap(src map[string]string) map[string]string { return dst } +func cloneStringSlice(src []string) []string { + return append([]string(nil), src...) +} + func hasAnyTrueFeature(features map[string]bool) bool { for _, enabled := range features { if enabled { diff --git a/internal/core/workspace/manager_test.go b/internal/core/workspace/manager_test.go index 75fa849..d87902e 100644 --- a/internal/core/workspace/manager_test.go +++ b/internal/core/workspace/manager_test.go @@ -120,6 +120,45 @@ func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testi } } +func TestListWorkspaceTemplatesExposesSelectableBuiltIns(t *testing.T) { + m := NewManager(newVaultDir(t)) + templates := m.ListWorkspaceTemplates() + + wantIDs := []string{"default", "project", "writing", "admin", "minimal"} + if len(templates) != len(wantIDs) { + t.Fatalf("templates = %+v, want %d selectable built-ins", templates, len(wantIDs)) + } + gotIDs := make([]string, 0, len(templates)) + for _, template := range templates { + gotIDs = append(gotIDs, template.ID) + if template.Name == "" || template.Description == "" || template.Version == 0 || len(template.WorkspaceTools) == 0 { + t.Fatalf("template is missing presentation or workspace tool data: %+v", template) + } + } + if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") { + t.Fatalf("template ids = %v, want %v", gotIDs, wantIDs) + } +} + +func TestCreateWorkspaceStoresWorkspaceToolSnapshot(t *testing.T) { + m := NewManager(newVaultDir(t)) + if _, err := m.CreateWorkspace("Minimal", "minimal"); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + meta, err := m.GetWorkspaceMetadata("Minimal") + if err != nil { + t.Fatalf("GetWorkspaceMetadata: %v", err) + } + wantTools := []string{"verstak.notes", "verstak.files"} + if strings.Join(meta.WorkspaceTools, ",") != strings.Join(wantTools, ",") { + t.Fatalf("metadata workspace tools = %v, want %v", meta.WorkspaceTools, wantTools) + } + if meta.CreatedFromTemplate == nil || strings.Join(meta.CreatedFromTemplate.WorkspaceTools, ",") != strings.Join(wantTools, ",") { + t.Fatalf("template snapshot workspace tools = %+v, want %v", meta.CreatedFromTemplate, wantTools) + } +} + func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) { vaultDir := newVaultDir(t) m := NewManager(vaultDir) @@ -136,6 +175,9 @@ func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) { if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" { t.Fatalf("snapshot not preserved after registry clear: %+v", meta.CreatedFromTemplate) } + if len(meta.WorkspaceTools) == 0 || len(meta.CreatedFromTemplate.WorkspaceTools) == 0 { + t.Fatalf("workspace tool snapshot not preserved after registry clear: %+v", meta) + } } func TestMissingMetadataReturnsGenericWorkspaceMetadata(t *testing.T) {