feat: add workspace templates and tab visibility

This commit is contained in:
mirivlad 2026-07-10 21:32:48 +08:00
parent 2007b909d1
commit e412cfc45a
14 changed files with 739 additions and 67 deletions

View File

@ -872,8 +872,8 @@ Workspace existence/list хранится только в filesystem:
`.verstak` может хранить только metadata, которая не заменяет filesystem: `.verstak` может хранить только metadata, которая не заменяет filesystem:
- UI state: selected workspace, expanded folders, sort/pin state, preferences. - UI state: selected workspace, expanded folders, sort/pin state, preferences.
- Semantic snapshot: applied template snapshot, enabled feature areas, folder - Semantic snapshot: applied template snapshot, enabled feature areas, exact
conventions. `workspaceTools`, and folder conventions.
Template snapshot копируется в metadata при создании workspace. Workspace Template snapshot копируется в metadata при создании workspace. Workspace
identity при этом остаётся именем top-level folder; `metadata.workspaceName` identity при этом остаётся именем top-level folder; `metadata.workspaceName`
@ -885,22 +885,39 @@ identity при этом остаётся именем top-level folder; `metada
{ {
"workspaceName": "Project", "workspaceName": "Project",
"createdFromTemplate": { "createdFromTemplate": {
"templateId": "client-project", "templateId": "project",
"templateName": "Client Project", "templateName": "Project",
"templateVersion": 1, "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": { "features": {
"notes": true, "notes": true,
"files": true, "files": true,
"secrets": true, "todo": true,
"activity": false "journal": true,
"activity": true,
"browser-inbox": true
}, },
"folders": { "folders": {
"notes": "Notes", "notes": "Notes",
"files": "Files", "files": "Files"
"secrets": "Secrets" },
} "workspaceTools": [
"verstak.notes",
"verstak.files",
"verstak.todo",
"verstak.journal",
"verstak.activity",
"verstak.browser-inbox"
]
} }
``` ```
@ -912,6 +929,8 @@ update/migration может быть только явной future feature. Е
### API ### API
- `ListWorkspaces()` — список top-level physical folders. - `ListWorkspaces()` — список top-level physical folders.
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation
metadata и `workspaceTools`.
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить - `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
template один раз, сохранить snapshot metadata. template один раз, сохранить snapshot metadata.
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder - `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder

View File

@ -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.

View File

@ -78,7 +78,7 @@ test.describe('E: Plugin Manager layout', () => {
const health = page.locator('[data-plugin-manager-summary="health"]'); const health = page.locator('[data-plugin-manager-summary="health"]');
await expect(health).toBeVisible(); 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="failed"]')).toContainText('0');
await expect(health.locator('[data-plugin-status-summary="disabled"]')).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 }) => { 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 workspace"]').click();
await page.locator('.wt-create input').fill('ClientA'); const modal = page.locator('[data-workspace-create-modal]');
await page.locator('.wt-btn-primary', { hasText: 'Create' }).click(); 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(); await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();

View File

@ -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();
});
});

View File

@ -11,7 +11,10 @@
let contributions = {}; let contributions = {};
let plugins = []; let plugins = [];
let discoveredWorkspaceTools = [];
let workspaceTools = []; let workspaceTools = [];
let workspaceMetadata = null;
let metadataWorkspaceRoot = '';
let toolsLoaded = false; let toolsLoaded = false;
let requestedToolKind = ''; let requestedToolKind = '';
let requestedToolRequest = null; let requestedToolRequest = null;
@ -27,6 +30,7 @@
['activity', 40], ['activity', 40],
['browser', 50], ['browser', 50],
['inbox', 50], ['inbox', 50],
['secrets', 60],
['search', 90], ['search', 90],
]); ]);
@ -34,6 +38,12 @@
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace'; $: workspaceType = selectedWorkspace?.type || 'workspace';
$: if (workspaceRootPath !== metadataWorkspaceRoot) {
metadataWorkspaceRoot = workspaceRootPath;
workspaceMetadata = null;
if (workspaceRootPath) loadWorkspaceMetadata(workspaceRootPath);
}
$: workspaceTools = sortWorkspaceTools(filterWorkspaceTools(discoveredWorkspaceTools, workspaceMetadata));
$: if (workspaceRootPath !== requestedWorkspaceRoot) { $: if (workspaceRootPath !== requestedWorkspaceRoot) {
requestedWorkspaceRoot = workspaceRootPath; requestedWorkspaceRoot = workspaceRootPath;
requestedToolRequest = null; 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) { function selectTool(tool, toolRequest = null) {
activeToolKey = toolKey(tool); activeToolKey = toolKey(tool);
activeToolRequest = toolRequest; activeToolRequest = toolRequest;
@ -137,7 +170,7 @@
plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id) 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) { } catch (e) {
console.error('[WorkspaceHost] loadTools error:', e); console.error('[WorkspaceHost] loadTools error:', e);
} finally { } finally {

View File

@ -15,6 +15,11 @@
let currentWorkspaceId = ''; let currentWorkspaceId = '';
let showCreate = false; let showCreate = false;
let newWorkspaceName = ''; let newWorkspaceName = '';
let workspaceTemplates = [];
let templatePluginNames = {};
let selectedTemplateId = 'default';
let createError = '';
let templatesLoading = false;
let creating = false; let creating = false;
let renamingId = ''; let renamingId = '';
let renameValue = ''; let renameValue = '';
@ -22,6 +27,7 @@
onMount(() => { onMount(() => {
loadWorkspaces(); loadWorkspaces();
loadWorkspaceTemplates();
window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged); window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
}); });
@ -35,9 +41,49 @@
} }
function resultOrError(response, fallbackValue) { 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, '']; 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) { function wsName(workspace) {
return String(workspace?.name || workspace?.rootPath || ''); return String(workspace?.name || workspace?.rootPath || '');
} }
@ -107,12 +153,19 @@
async function doCreate() { async function doCreate() {
const name = newWorkspaceName.trim(); const name = newWorkspaceName.trim();
if (!name) return; if (!name) {
createError = 'Name is required';
return;
}
if (!selectedTemplate) {
createError = 'Choose a workspace template';
return;
}
creating = true; creating = true;
localError = ''; createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, 'default'), null); const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
if (err) { if (err) {
localError = err; createError = err;
creating = false; creating = false;
return; return;
} }
@ -124,6 +177,20 @@
if (created) await selectWorkspace(created); 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) { function startRename(workspace) {
renamingId = wsName(workspace); renamingId = wsName(workspace);
renameValue = renamingId; renameValue = renamingId;
@ -180,7 +247,7 @@
<div class="wt"> <div class="wt">
<div class="wt-header"> <div class="wt-header">
<span class="wt-title">Workspaces</span> <span class="wt-title">Workspaces</span>
<button class="wt-btn" on:click={() => { showCreate = true; newWorkspaceName = ''; }} title="New workspace" type="button">+</button> <button class="wt-btn" on:click={openCreateDialog} title="New workspace" type="button">+</button>
</div> </div>
{#if loading} {#if loading}
@ -222,15 +289,43 @@
</div> </div>
{#if showCreate} {#if showCreate}
<div class="wt-create"> <div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label="Create workspace">
<div class="wt-create-header"> <div class="workspace-create-modal">
<span>New workspace</span> <div class="workspace-create-header">
<button class="wt-btn btn-ghost" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button">Close</button> <div>
</div> <h2>New workspace</h2>
<input type="text" bind:value={newWorkspaceName} placeholder="Name..." disabled={creating} /> </div>
<div class="wt-create-actions"> <button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>Close</button>
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || !newWorkspaceName.trim()}>{creating ? '...' : 'Create'}</button> </div>
<button class="wt-btn" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button" disabled={creating}>Cancel</button> <label class="workspace-create-field">
<span>Name</span>
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder="Workspace name" disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
</label>
<label class="workspace-create-field">
<span>Template</span>
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
{#each workspaceTemplates as template (template.id)}
<option value={template.id}>{template.name}</option>
{/each}
</select>
</label>
{#if selectedTemplate}
<div class="workspace-template-summary">
<p data-workspace-template-description>{selectedTemplate.description}</p>
<div class="workspace-template-tools" data-workspace-template-tools>
{#each selectedTemplate.workspaceTools || [] as pluginId (pluginId)}
<span>{toolLabel(pluginId)}</span>
{/each}
</div>
</div>
{/if}
{#if createError}
<p class="workspace-create-error" data-workspace-create-error role="alert">{createError}</p>
{/if}
<div class="workspace-create-actions">
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || templatesLoading || !selectedTemplate}>{creating ? 'Creating...' : 'Create workspace'}</button>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>Cancel</button>
</div>
</div> </div>
</div> </div>
{/if} {/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-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 { 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-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; } .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); }
.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; } .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); }
.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; } .workspace-create-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.wt-create input:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); } .workspace-create-header h2 { margin: 0; font-size: 1rem; }
.wt-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; } .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 { 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:hover:not(:disabled) { background: #3dbb92; }
.wt-btn-primary:disabled, .wt-btn:disabled, .wt-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; } .wt-btn-primary:disabled, .wt-btn:disabled, .wt-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }

View File

@ -255,6 +255,7 @@
error: '' error: ''
}, },
'verstak.todo': makeTodoPluginState(), 'verstak.todo': makeTodoPluginState(),
'verstak.secrets': makeSecretsPluginState(),
'verstak.search': { 'verstak.search': {
status: 'loaded', status: 'loaded',
enabled: true, enabled: true,
@ -288,6 +289,8 @@
vaultPluginState.desiredPlugins.push({ id: 'verstak.trash', version: '0.1.0', source: 'official' }); vaultPluginState.desiredPlugins.push({ id: 'verstak.trash', version: '0.1.0', source: 'official' });
vaultPluginState.enabledPlugins.push('verstak.todo'); vaultPluginState.enabledPlugins.push('verstak.todo');
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' }); 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 appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
var workbenchPreferences = {}; var workbenchPreferences = {};
var openedResources = []; var openedResources = [];
@ -301,6 +304,7 @@
var trashPayloads = {}; var trashPayloads = {};
window.__wailsMockExternalOpens = []; window.__wailsMockExternalOpens = [];
var workspaceTree = makeDefaultWorkspaceTree(); var workspaceTree = makeDefaultWorkspaceTree();
var workspaceMetadata = {};
var reloadResponseMode = 'tuple'; var reloadResponseMode = 'tuple';
var syncState = makeDefaultSyncState(); var syncState = makeDefaultSyncState();
@ -334,6 +338,93 @@
return { id: name, parentId: '', type: 'space', title: name, name: name, rootPath: name, status: 'active', order: order }; 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() { function makeDefaultVaultFiles() {
return { return {
'': { type: 'folder', modifiedAt: new Date().toISOString() }, '': { 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() { function makeDefaultSyncState() {
return { return {
configured: false, configured: false,
@ -3281,6 +3397,9 @@
if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(todoBundle()); 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') { if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(searchPluginBundle()); return Promise.resolve(searchPluginBundle());
} }
@ -3507,15 +3626,30 @@
ListWorkspaces: function () { ListWorkspaces: function () {
return Promise.resolve(listWorkspacesFromTree()); 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); var norm = normalizeVaultPath(name, false);
if (norm.error || norm.path !== String(name || '').trim() || norm.path.indexOf('/') !== -1) { if (norm.error || norm.path !== String(name || '').trim() || norm.path.indexOf('/') !== -1) {
return Promise.resolve(norm.error || 'invalid-workspace-name'); return Promise.resolve(norm.error || 'invalid-workspace-name');
} }
if (vaultFiles[norm.path]) return Promise.resolve('conflict: ' + norm.path); 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] = { type: 'folder', modifiedAt: new Date().toISOString() };
vaultFiles[norm.path + '/Notes'] = { type: 'folder', modifiedAt: new Date().toISOString() }; template.folders.forEach(function (folder) {
vaultFiles[norm.path + '/Notes/Overview.md'] = { type: 'file', content: '# Overview\n', modifiedAt: new Date().toISOString() }; 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)); workspaceTree.nodes.push(makeWorkspaceNode(norm.path, workspaceTree.nodes.length + 1));
return Promise.resolve({ name: norm.path, rootPath: norm.path }); return Promise.resolve({ name: norm.path, rootPath: norm.path });
}, },
@ -3537,6 +3671,10 @@
if (n.id !== oldNorm.path) return n; if (n.id !== oldNorm.path) return n;
return makeWorkspaceNode(newNorm.path, n.order); 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; if (workspaceTree.currentNodeId === oldNorm.path) workspaceTree.currentNodeId = newNorm.path;
return Promise.resolve(''); return Promise.resolve('');
}, },
@ -3548,6 +3686,7 @@
return path === norm.path || path.indexOf(norm.path + '/') === 0; return path === norm.path || path.indexOf(norm.path + '/') === 0;
}).forEach(function (path) { delete vaultFiles[path]; }); }).forEach(function (path) { delete vaultFiles[path]; });
workspaceTree.nodes = workspaceTree.nodes.filter(function (n) { return n.id !== norm.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 : ''; 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() }); 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); var norm = normalizeVaultPath(name, false);
if (norm.error) return Promise.resolve(norm.error); if (norm.error) return Promise.resolve(norm.error);
if (!vaultFiles[norm.path]) return Promise.resolve('not-found: ' + norm.path); if (!vaultFiles[norm.path]) return Promise.resolve('not-found: ' + norm.path);
return Promise.resolve({ return Promise.resolve(cloneJson(workspaceMetadata[norm.path] || genericWorkspaceMetadata(norm.path)));
workspaceName: norm.path,
features: { files: true },
folders: { notes: 'Notes', files: 'Files' }
});
}, },
UpdateWorkspaceMetadata: function (name, patch) { 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 () { GetCurrentWorkspace: function () {
var found = workspaceTree.nodes.find(function (n) { return n.id === workspaceTree.currentNodeId; }); var found = workspaceTree.nodes.find(function (n) { return n.id === workspaceTree.currentNodeId; });
@ -3891,6 +4031,7 @@
error: '' error: ''
}, },
'verstak.todo': makeTodoPluginState(), 'verstak.todo': makeTodoPluginState(),
'verstak.secrets': makeSecretsPluginState(),
'verstak.search': { 'verstak.search': {
status: 'loaded', status: 'loaded',
enabled: true, enabled: true,
@ -3923,6 +4064,8 @@
vaultPluginState.desiredPlugins.push({ id: 'verstak.trash', version: '0.1.0', source: 'official' }); vaultPluginState.desiredPlugins.push({ id: 'verstak.trash', version: '0.1.0', source: 'official' });
vaultPluginState.enabledPlugins.push('verstak.todo'); vaultPluginState.enabledPlugins.push('verstak.todo');
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' }); 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: [] }; appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
workbenchPreferences = {}; workbenchPreferences = {};
openedResources = []; openedResources = [];
@ -3933,6 +4076,7 @@
trashPayloads = {}; trashPayloads = {};
window.__wailsMockExternalOpens = []; window.__wailsMockExternalOpens = [];
workspaceTree = makeDefaultWorkspaceTree(); workspaceTree = makeDefaultWorkspaceTree();
workspaceMetadata = {};
reloadResponseMode = 'tuple'; reloadResponseMode = 'tuple';
syncState = makeDefaultSyncState(); syncState = makeDefaultSyncState();
}, },

View File

@ -72,6 +72,8 @@ export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|stri
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>; export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>; export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>; export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;

View File

@ -130,6 +130,10 @@ export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces'](); return window['go']['api']['App']['ListWorkspaces']();
} }
export function ListWorkspaceTemplates() {
return window['go']['api']['App']['ListWorkspaceTemplates']();
}
export function MoveVaultPath(arg1, arg2, arg3, arg4) { export function MoveVaultPath(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4); return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4);
} }

View File

@ -1213,12 +1213,34 @@ export namespace workbench {
} }
export namespace workspace { 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 { export class TemplateSnapshot {
templateId: string; templateId: string;
templateName: string; templateName: string;
templateVersion: number; templateVersion: number;
appliedAt: string; appliedAt: string;
workspaceTools?: string[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new TemplateSnapshot(source); return new TemplateSnapshot(source);
@ -1230,6 +1252,7 @@ export namespace workspace {
this.templateName = source["templateName"]; this.templateName = source["templateName"];
this.templateVersion = source["templateVersion"]; this.templateVersion = source["templateVersion"];
this.appliedAt = source["appliedAt"]; this.appliedAt = source["appliedAt"];
this.workspaceTools = source["workspaceTools"];
} }
} }
export class Metadata { export class Metadata {
@ -1237,6 +1260,7 @@ export namespace workspace {
createdFromTemplate?: TemplateSnapshot; createdFromTemplate?: TemplateSnapshot;
features?: Record<string, boolean>; features?: Record<string, boolean>;
folders?: Record<string, string>; folders?: Record<string, string>;
workspaceTools?: string[];
updatedAt?: string; updatedAt?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@ -1249,6 +1273,7 @@ export namespace workspace {
this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot); this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot);
this.features = source["features"]; this.features = source["features"];
this.folders = source["folders"]; this.folders = source["folders"];
this.workspaceTools = source["workspaceTools"];
this.updatedAt = source["updatedAt"]; this.updatedAt = source["updatedAt"];
} }

View File

@ -1735,6 +1735,14 @@ func (a *App) ListWorkspaces() ([]workspace.Workspace, string) {
return workspaces, "" 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. // CreateWorkspace creates a top-level physical workspace folder.
func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) { func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) {
if a.workspace == nil { if a.workspace == nil {

View File

@ -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) { func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"}) app, vaultDir := newFilesTestApp(t, []string{"files.read"})
app.workspace = workspace.NewManager(vaultDir) app.workspace = workspace.NewManager(vaultDir)

View File

@ -46,10 +46,21 @@ type Workspace struct {
// TemplateSnapshot is copied into workspace metadata when a template is applied. // TemplateSnapshot is copied into workspace metadata when a template is applied.
type TemplateSnapshot struct { type TemplateSnapshot struct {
TemplateID string `json:"templateId"` TemplateID string `json:"templateId"`
TemplateName string `json:"templateName"` TemplateName string `json:"templateName"`
TemplateVersion int `json:"templateVersion"` TemplateVersion int `json:"templateVersion"`
AppliedAt string `json:"appliedAt"` 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 // Metadata stores semantic workspace metadata that is not the source of truth
@ -59,6 +70,7 @@ type Metadata struct {
CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"` CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"`
Features map[string]bool `json:"features,omitempty"` Features map[string]bool `json:"features,omitempty"`
Folders map[string]string `json:"folders,omitempty"` Folders map[string]string `json:"folders,omitempty"`
WorkspaceTools []string `json:"workspaceTools,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"`
} }
@ -102,35 +114,127 @@ type WorkspaceTree struct {
} }
type templateDefinition struct { type templateDefinition struct {
ID string ID string
Name string Name string
Version int Description string
Features map[string]bool Version int
Folders map[string]string Features map[string]bool
Files map[string]string Folders map[string]string
Files map[string]string
WorkspaceTools []string
Selectable bool
Order int
} }
var builtInTemplates = map[string]templateDefinition{ var builtInTemplates = map[string]templateDefinition{
"default": { "default": {
ID: "default", ID: "default",
Name: "Default Workspace", Name: "General",
Version: 1, Description: "Everyday workspace with notes, files, journal, activity, and browser captures.",
Version: 2,
Features: map[string]bool{ Features: map[string]bool{
"files": true, "files": true,
"notes": true, "notes": true,
"secrets": false, "secrets": false,
"activity": false, "activity": true,
"journal": true,
"browser-inbox": true,
}, },
Folders: map[string]string{ Folders: map[string]string{
"notes": "Notes", "notes": "Notes",
"files": "Files", "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": { "client-project": {
ID: "client-project", ID: "client-project",
Name: "Client Project", Name: "Client Project",
Version: 1, Description: "Legacy client project template retained for existing integrations.",
Version: 1,
Features: map[string]bool{ Features: map[string]bool{
"files": true, "files": true,
"notes": true, "notes": true,
@ -142,7 +246,10 @@ var builtInTemplates = map[string]templateDefinition{
"files": "Files", "files": "Files",
"secrets": "Secrets", "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, TemplateName: template.Name,
TemplateVersion: template.Version, TemplateVersion: template.Version,
AppliedAt: now, AppliedAt: now,
WorkspaceTools: cloneStringSlice(template.WorkspaceTools),
}, },
Features: cloneBoolMap(template.Features), Features: cloneBoolMap(template.Features),
Folders: cloneStringMap(template.Folders), Folders: cloneStringMap(template.Folders),
UpdatedAt: now, WorkspaceTools: cloneStringSlice(template.WorkspaceTools),
UpdatedAt: now,
} }
if err := m.writeMetadata(name, meta); err != nil { if err := m.writeMetadata(name, meta); err != nil {
return Workspace{}, err return Workspace{}, err
@ -256,6 +365,27 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
return Workspace{Name: name, RootPath: name}, nil 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. // RenameWorkspace physically renames a top-level workspace folder and metadata key.
func (m *Manager) RenameWorkspace(oldName, newName string) error { func (m *Manager) RenameWorkspace(oldName, newName string) error {
oldName = strings.TrimSpace(oldName) oldName = strings.TrimSpace(oldName)
@ -748,6 +878,10 @@ func cloneStringMap(src map[string]string) map[string]string {
return dst return dst
} }
func cloneStringSlice(src []string) []string {
return append([]string(nil), src...)
}
func hasAnyTrueFeature(features map[string]bool) bool { func hasAnyTrueFeature(features map[string]bool) bool {
for _, enabled := range features { for _, enabled := range features {
if enabled { if enabled {

View File

@ -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) { func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
m := NewManager(vaultDir) m := NewManager(vaultDir)
@ -136,6 +175,9 @@ func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) {
if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" { if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" {
t.Fatalf("snapshot not preserved after registry clear: %+v", meta.CreatedFromTemplate) 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) { func TestMissingMetadataReturnsGenericWorkspaceMetadata(t *testing.T) {