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:
- 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?)` — создать `<vault>/<name>/`, применить
template один раз, сохранить snapshot metadata.
- `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"]');
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();

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 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 {

View File

@ -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 @@
<div class="wt">
<div class="wt-header">
<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>
{#if loading}
@ -222,15 +289,43 @@
</div>
{#if showCreate}
<div class="wt-create">
<div class="wt-create-header">
<span>New workspace</span>
<button class="wt-btn btn-ghost" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button">Close</button>
</div>
<input type="text" bind:value={newWorkspaceName} placeholder="Name..." disabled={creating} />
<div class="wt-create-actions">
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || !newWorkspaceName.trim()}>{creating ? '...' : 'Create'}</button>
<button class="wt-btn" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button" disabled={creating}>Cancel</button>
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label="Create workspace">
<div class="workspace-create-modal">
<div class="workspace-create-header">
<div>
<h2>New workspace</h2>
</div>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>Close</button>
</div>
<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>
{/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; }

View File

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

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 ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):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']();
}
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);
}

View File

@ -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<string, boolean>;
folders?: Record<string, string>;
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"];
}

View File

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

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) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
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.
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 {

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) {
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) {