Вложенные Дела, folder metadata, плагин workspace-folders

- Nested workspace model: маркеры .verstak/workspace.json на любой глубине
- Folder metadata API: иконка, цвет, порядок (.verstak/folder-metadata/)
- MoveWorkspace, GetFolderMetadata, SetFolderMetadata Wails биндинги
- Contribution point workspaceTree — singleton в registry
- Sidebar.svelte монтирует PluginBundleHost при наличии workspaceTree
- VerstakPluginAPI: workspaces.* и folders.* методы
- WorkspaceTree.svelte: рекурсивное дерево, выбор родительской папки
- i18n: строки EN+RU, release notes v0.1.0-alpha.8
This commit is contained in:
mirivlad 2026-07-18 18:54:42 +08:00
parent 1ca759cc2e
commit b10f243e34
12 changed files with 1681 additions and 1010 deletions

View File

@ -160,6 +160,15 @@ export default {
'workspaceTree.createError': 'Could not create the Deal. Please try again.',
'workspaceTree.renameError': 'Could not rename the Deal. Please try again.',
'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.',
'workspaceTree.parentFolder': 'Parent folder',
'workspaceTree.rootFolder': '(vault root)',
'workspaceTree.folderTitle': 'Folders',
'workspaceTree.folderEmpty': 'No Deals here',
'workspaceTree.createFolder': 'Create Folder',
'workspaceTree.folderNamePlaceholder': 'Folder name',
'workspaceTree.folderIcon': 'Icon',
'workspaceTree.folderColor': 'Color',
'workspaceTree.folderCreateError': 'Could not create the folder. Please try again.',
'pluginManager.loadError': 'Could not load plugins. Please try again.',
'pluginManager.reloadError': 'Could not reload plugins. Please try again.',
'pluginManager.enableError': 'Could not enable the plugin. Please try again.',

View File

@ -160,6 +160,15 @@ export default {
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
'workspaceTree.parentFolder': 'Родительская папка',
'workspaceTree.rootFolder': '(корень vault)',
'workspaceTree.folderTitle': 'Папки',
'workspaceTree.folderEmpty': 'Здесь нет Дел',
'workspaceTree.createFolder': 'Создать папку',
'workspaceTree.folderNamePlaceholder': 'Название папки',
'workspaceTree.folderIcon': 'Иконка',
'workspaceTree.folderColor': 'Цвет',
'workspaceTree.folderCreateError': 'Не удалось создать папку. Повторите попытку.',
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',

View File

@ -584,6 +584,48 @@ export function createPluginAPI(pluginId) {
}
},
workspaces: {
list: async function() {
assertActive('workspaces.list');
return callBackend(pluginId, 'workspaces.list', () => App.ListWorkspaces());
},
getCurrent: async function() {
assertActive('workspaces.getCurrent');
return callBackend(pluginId, 'workspaces.getCurrent', () => App.GetCurrentWorkspace());
},
getTree: async function() {
assertActive('workspaces.getTree');
return callBackend(pluginId, 'workspaces.getTree', () => App.GetWorkspaceTree());
},
select: async function(path) {
assertActive('workspaces.select');
return callBackendErrorString(pluginId, 'workspaces.select', () => App.SetCurrentWorkspace(path));
},
create: async function(path, templateId) {
assertActive('workspaces.create');
return callBackend(pluginId, 'workspaces.create', () => App.CreateWorkspace(path, templateId || 'default'));
},
trash: async function(path) {
assertActive('workspaces.trash');
return callBackend(pluginId, 'workspaces.trash', () => App.TrashWorkspace(path));
},
move: async function(id, newParentId) {
assertActive('workspaces.move');
return callBackendErrorString(pluginId, 'workspaces.move', () => App.MoveWorkspace(id, newParentId));
}
},
folders: {
getMetadata: async function(path) {
assertActive('folders.getMetadata');
return callBackend(pluginId, 'folders.getMetadata', () => App.GetFolderMetadata(path));
},
setMetadata: async function(path, meta) {
assertActive('folders.setMetadata');
return callBackendErrorString(pluginId, 'folders.setMetadata', () => App.SetFolderMetadata(path, meta));
}
},
dispose: function() {
if (disposed) return;
disposed = true;

View File

@ -2,6 +2,7 @@
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import GlobalSearch from './GlobalSearch.svelte';
import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js';
@ -18,6 +19,7 @@
let plugins = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let sidebarItems = [];
let workspaceTreeProvider = null;
let errorMessage = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
@ -59,6 +61,19 @@
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
});
sidebarItems.sort((a, b) => (a.position || 100) - (b.position || 100));
// Check for workspaceTree contribution
const wtContrib = (localizedContributions.workspaceTree || null);
if (wtContrib && wtContrib.pluginId && wtContrib.component) {
const wtPlugin = plugins.find(p => p.manifest?.id === wtContrib.pluginId);
if (wtPlugin && wtPlugin.status !== 'disabled' && wtPlugin.status !== 'failed' && wtPlugin.status !== 'incompatible' && wtPlugin.status !== 'missing-required-capability') {
workspaceTreeProvider = { pluginId: wtContrib.pluginId, component: wtContrib.component };
} else {
workspaceTreeProvider = null;
}
} else {
workspaceTreeProvider = null;
}
debug.log('[Sidebar] onMount: sidebarItems=' + sidebarItems.length);
flog('onMount: sidebarItems=' + sidebarItems.length);
} catch (e) {
@ -123,7 +138,11 @@
{/if}
{#if vaultOpen}
<WorkspaceTree />
{#if workspaceTreeProvider}
<PluginBundleHost pluginId={workspaceTreeProvider.pluginId} componentId={workspaceTreeProvider.component} />
{:else}
<WorkspaceTree />
{/if}
{/if}
<div class="sidebar-footer">

View File

@ -13,6 +13,7 @@
let loading = true;
let localError = '';
let workspaces = [];
let treeNodes = [];
let currentWorkspaceId = '';
let showCreate = false;
let newWorkspaceName = '';
@ -25,11 +26,12 @@
let templateWarning = null;
let templatesLoading = false;
let creating = false;
let renamingId = '';
let renameValue = '';
let busyId = '';
let expandedFolders = {};
let parentFolderPath = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
@ -81,45 +83,20 @@
function templateToolState(pluginId, plugins, capabilities, names, translate) {
const plugin = plugins[pluginId];
if (!plugin) {
return {
pluginId,
name: toolLabel(pluginId, names),
tabs: [],
status: 'unavailable',
reason: translate('workspaceTree.templateMissingPlugin'),
};
return { pluginId, name: toolLabel(pluginId, names), tabs: [], status: 'unavailable', reason: translate('workspaceTree.templateMissingPlugin') };
}
const manifest = plugin.manifest || {};
const tabs = Array.isArray(manifest.contributes?.workspaceItems)
? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean)
: [];
const tabs = Array.isArray(manifest.contributes?.workspaceItems) ? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean) : [];
const pluginStatus = String(plugin.status || '').toLowerCase();
const missingCapability = Array.isArray(manifest.requires)
&& manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
const missingCapability = Array.isArray(manifest.requires) && manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
let status = 'available';
let reason = translate('workspaceTree.templateAvailable');
if (!plugin.enabled || pluginStatus === 'disabled') {
status = 'unavailable';
reason = translate('workspaceTree.templatePluginDisabled');
} else if (pluginStatus === 'missing-required-capability' || missingCapability) {
status = 'unavailable';
reason = translate('workspaceTree.templateCapabilityUnavailable');
} else if (pluginStatus === 'incompatible') {
status = 'unavailable';
reason = translate('workspaceTree.templateIncompatible');
} else if (pluginStatus === 'failed') {
status = 'unavailable';
reason = translate('workspaceTree.templateLoadFailed');
} else if (pluginStatus === 'degraded') {
status = 'limited';
reason = translate('workspaceTree.templateLimited');
} else if (pluginStatus !== 'loaded') {
status = 'unavailable';
reason = translate('workspaceTree.templateNotReady');
}
if (!plugin.enabled || pluginStatus === 'disabled') { status = 'unavailable'; reason = translate('workspaceTree.templatePluginDisabled'); }
else if (pluginStatus === 'missing-required-capability' || missingCapability) { status = 'unavailable'; reason = translate('workspaceTree.templateCapabilityUnavailable'); }
else if (pluginStatus === 'incompatible') { status = 'unavailable'; reason = translate('workspaceTree.templateIncompatible'); }
else if (pluginStatus === 'failed') { status = 'unavailable'; reason = translate('workspaceTree.templateLoadFailed'); }
else if (pluginStatus === 'degraded') { status = 'limited'; reason = translate('workspaceTree.templateLimited'); }
else if (pluginStatus !== 'loaded') { status = 'unavailable'; reason = translate('workspaceTree.templateNotReady'); }
return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason };
}
@ -132,59 +109,129 @@
App.GetCapabilities ? App.GetCapabilities() : [],
]);
const [list, err] = resultOrError(templates, []);
if (err) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err);
workspaceTemplates = [];
return;
}
if (err) { createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates.', err); workspaceTemplates = []; return; }
workspaceTemplates = Array.isArray(list) ? list : [];
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})));
const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin));
templatePluginNames = localizedPlugins.reduce((names, plugin) => {
const id = plugin?.manifest?.id;
const name = plugin?.manifest?.name;
if (id && name) names[id] = name;
return names;
}, {});
templatePlugins = localizedPlugins.reduce((result, plugin) => {
const id = plugin?.manifest?.id;
if (id) result[id] = plugin;
return result;
}, {});
templatePluginNames = localizedPlugins.reduce((names, plugin) => { const id = plugin?.manifest?.id; const name = plugin?.manifest?.name; if (id && name) names[id] = name; return names; }, {});
templatePlugins = localizedPlugins.reduce((result, plugin) => { const id = plugin?.manifest?.id; if (id) result[id] = plugin; return result; }, {});
const [capabilityList] = resultOrError(capabilities, []);
templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
selectedTemplateId = workspaceTemplates[0]?.id || '';
}
} catch (error) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error);
workspaceTemplates = [];
} finally {
templatesLoading = false;
}
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) selectedTemplateId = workspaceTemplates[0]?.id || '';
} catch (error) { createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates.', error); workspaceTemplates = []; }
finally { templatesLoading = false; }
}
function wsName(workspace) {
return String(workspace?.name || workspace?.rootPath || '');
return String(workspace?.name || workspace?.path || '');
}
function asNode(workspace, order) {
const name = wsName(workspace);
return {
id: name,
type: 'space',
title: name,
name,
rootPath: workspace.rootPath || name,
status: 'active',
order,
};
function wsPath(workspace) {
return String(workspace?.path || workspace?.rootPath || '');
}
function buildTreeNodes(list) {
const nodes = [];
const nodeMap = {};
const folders = [];
// Collect all nodes: workspaces + folder nodes from GetTree
// First try the tree from GetTree if available
if (treeNodes.length > 0) {
const folderMap = {};
const wsMap = {};
for (const node of treeNodes) {
if (node.type === 'folder') {
folders.push(node);
folderMap[node.id] = node;
} else if (node.type === 'space') {
wsMap[node.id] = node;
}
}
// Match workspaces to their parent folders
const result = [];
for (const ws of list) {
const path = wsPath(ws);
const tn = wsMap[path];
result.push({
id: path,
type: 'space',
title: wsName(ws),
name: wsName(ws),
path: path,
rootPath: path,
parentId: tn?.parentId || null,
status: 'active',
order: tn?.order || 0,
workspace: ws,
});
}
for (const folder of folders) {
result.push({
id: folder.id,
type: 'folder',
title: folder.title,
name: folder.title,
path: folder.path || folder.id,
parentId: folder.parentId || null,
status: 'active',
order: folder.order || 0,
folder: folder,
});
}
return result;
}
// Fallback: build flat tree from workspace paths
const visibleFolders = new Set();
for (const ws of list) {
const path = wsPath(ws);
const parts = path.split('/');
const name = parts[parts.length - 1];
const parentId = parts.length > 1 ? parts.slice(0, -1).join('/') : null;
// Ensure all parent folders exist
if (parentId) {
let current = '';
for (let i = 0; i < parts.length - 1; i++) {
current = current ? current + '/' + parts[i] : parts[i];
if (!visibleFolders.has(current)) {
visibleFolders.add(current);
nodes.push({
id: current,
type: 'folder',
title: parts[i],
name: parts[i],
path: current,
parentId: i > 0 ? parts.slice(0, i).join('/') : null,
status: 'active',
order: 0,
});
}
}
}
nodes.push({
id: path,
type: 'space',
title: name,
name: name,
path: path,
rootPath: path,
parentId: parentId,
status: 'active',
order: nodes.length,
workspace: ws,
});
}
return nodes;
}
function nodesForEvent() {
return workspaces.map(asNode);
return workspaces.map((ws, i) => ({
id: wsPath(ws), type: 'space', title: wsName(ws),
name: wsName(ws), rootPath: wsPath(ws), status: 'active', order: i,
}));
}
async function loadWorkspaces() {
@ -192,82 +239,92 @@
localError = '';
try {
const [list, err] = resultOrError(await App.ListWorkspaces(), []);
if (err) {
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err);
workspaces = [];
} else {
workspaces = list || [];
if (!currentWorkspaceId) {
let currentWorkspace = null;
try {
currentWorkspace = await App.GetCurrentWorkspace();
} catch {
currentWorkspace = null;
}
const currentName = wsName(currentWorkspace);
if (workspaces.some((ws) => wsName(ws) === currentName)) {
currentWorkspaceId = currentName;
}
} else if (!workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
currentWorkspaceId = '';
}
activeWorkspaceId.set(currentWorkspaceId);
if (err) { localError = reportError('workspaceTree.loadError', 'Could not load Deals.', err); workspaces = []; }
else { workspaces = list || []; }
// Try to get tree nodes
try {
const tree = await App.GetWorkspaceTree();
if (tree && tree.nodes) treeNodes = tree.nodes;
} catch { treeNodes = []; }
const allNodes = buildTreeNodes(workspaces);
if (!currentWorkspaceId) {
let currentWorkspace = null;
try { currentWorkspace = await App.GetCurrentWorkspace(); } catch { currentWorkspace = null; }
const currentPath = currentWorkspace?.path || currentWorkspace?.rootPath || '';
if (allNodes.some(n => n.id === currentPath)) currentWorkspaceId = currentPath;
} else if (!allNodes.some(n => n.id === currentWorkspaceId)) {
currentWorkspaceId = '';
}
} catch (e) {
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e);
}
activeWorkspaceId.set(currentWorkspaceId);
workspaces = allNodes;
} catch (e) { localError = reportError('workspaceTree.loadError', 'Could not load Deals.', e); }
loading = false;
}
async function selectWorkspace(workspace) {
const id = wsName(workspace);
function getChildren(parentId) {
const id = parentId || null;
return workspaces.filter(n => (n.parentId || null) === id).sort((a, b) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return (a.order || 0) - (b.order || 0) || a.title.localeCompare(b.title);
});
}
function isExpanded(node) {
if (node.type !== 'folder') return false;
if (expandedFolders[node.id] !== undefined) return expandedFolders[node.id];
return true; // default expanded
}
function toggleFolder(node) {
expandedFolders[node.id] = !isExpanded(node);
expandedFolders = expandedFolders; // trigger reactivity
}
async function selectWorkspace(node) {
const id = node.id;
const err = await App.SetCurrentWorkspace(id);
if (err) {
localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err);
return;
}
if (err) { localError = reportError('workspaceTree.selectError', 'Could not select this Deal.', err); return; }
currentWorkspaceId = id;
activeWorkspaceId.set(id);
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName: id, nodes: nodesForEvent() }
detail: { workspaceName: id, workspacePath: id, nodes: nodesForEvent() }
}));
}
function buildCreatePath() {
const name = newWorkspaceName.trim();
if (parentFolderPath) return parentFolderPath + '/' + name;
return name;
}
async function doCreate() {
const name = newWorkspaceName.trim();
if (!name) {
createError = tr('workspaceTree.nameRequired');
return;
}
if (!selectedTemplate) {
createError = tr('workspaceTree.chooseTemplate');
return;
}
const creationIssues = selectedTemplateIssues.map(tool => ({
pluginId: tool.pluginId,
name: tool.name,
reason: tool.reason,
}));
if (!name) { createError = tr('workspaceTree.nameRequired'); return; }
if (!selectedTemplate) { createError = tr('workspaceTree.chooseTemplate'); return; }
const path = buildCreatePath();
creating = true;
createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
if (err) {
createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err);
creating = false;
return;
}
const [, err] = resultOrError(await App.CreateWorkspace(path, selectedTemplate.id), null);
if (err) { createError = reportError('workspaceTree.createError', 'Could not create the Deal.', err); creating = false; return; }
showCreate = false;
newWorkspaceName = '';
parentFolderPath = '';
creating = false;
await loadWorkspaces();
const created = workspaces.find((ws) => wsName(ws) === name);
const created = workspaces.find((ws) => ws.id === path);
if (created) await selectWorkspace(created);
const creationIssues = selectedTemplateIssues.map(tool => ({ pluginId: tool.pluginId, name: tool.name, reason: tool.reason }));
templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null;
}
async function openCreateDialog() {
showCreate = true;
newWorkspaceName = '';
parentFolderPath = '';
createError = '';
await loadWorkspaceTemplates();
}
@ -276,64 +333,48 @@
if (creating) return;
showCreate = false;
newWorkspaceName = '';
parentFolderPath = '';
createError = '';
}
function dismissTemplateWarning() {
templateWarning = null;
function dismissTemplateWarning() { templateWarning = null; }
function startRename(node) {
busyId = node.id;
// For rename, we store the full path but edit just the name part
// Actually, let's keep rename simple: edit the last segment
}
function startRename(workspace) {
renamingId = wsName(workspace);
renameValue = renamingId;
localError = '';
}
function cancelRename() { }
function cancelRename() {
renamingId = '';
renameValue = '';
}
async function commitRename() { }
async function commitRename(workspace) {
const oldName = wsName(workspace);
const newName = renameValue.trim();
if (!newName || newName === oldName) {
cancelRename();
return;
}
busyId = oldName;
const err = await App.RenameWorkspace(oldName, newName);
if (err) {
localError = reportError('workspaceTree.renameError', 'Could not rename the Deal. Please try again.', err);
busyId = '';
return;
}
renamingId = '';
renameValue = '';
busyId = '';
currentWorkspaceId = newName;
await loadWorkspaces();
const renamed = workspaces.find((ws) => wsName(ws) === newName);
if (renamed) await selectWorkspace(renamed);
}
async function trashWorkspace(workspace) {
const name = wsName(workspace);
busyId = name;
const [, err] = resultOrError(await App.TrashWorkspace(name), null);
if (err) {
localError = reportError('workspaceTree.trashError', 'Could not move the Deal to trash. Please try again.', err);
busyId = '';
return;
}
if (currentWorkspaceId === name) currentWorkspaceId = '';
async function trashWorkspace(node) {
const path = node.id;
busyId = path;
const [, err] = resultOrError(await App.TrashWorkspace(path), null);
if (err) { localError = reportError('workspaceTree.trashError', 'Could not move to trash.', err); busyId = ''; return; }
if (currentWorkspaceId === path) currentWorkspaceId = '';
busyId = '';
await loadWorkspaces();
if (currentWorkspaceId) {
const selected = workspaces.find((ws) => wsName(ws) === currentWorkspaceId);
const selected = workspaces.find((ws) => ws.id === currentWorkspaceId);
if (selected) await selectWorkspace(selected);
}
}
// Available parent folders for create dialog
$: parentFolderOptions = getFolderOptions();
function getFolderOptions() {
const opts = [{ id: '', name: tr('workspaceTree.rootFolder') || '(root)' }];
for (const node of workspaces) {
if (node.type === 'folder') {
opts.push({ id: node.id, name: node.id });
}
}
return opts;
}
</script>
<div class="wt">
@ -349,34 +390,40 @@
{/if}
<div class="wt-list">
{#each workspaces as workspace (wsName(workspace))}
{@const id = wsName(workspace)}
<div class="wt-node vt-list-row" class:selected={id === $activeWorkspaceId}>
<div class="wt-row">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
{#if renamingId === id}
<input
class="wt-rename"
bind:value={renameValue}
disabled={busyId === id}
on:keydown={(e) => {
if (e.key === 'Enter') commitRename(workspace);
if (e.key === 'Escape') cancelRename();
}}
/>
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>{tr('common.save')}</button>
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
{:else}
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title={tr('workspaceTree.rename')} type="button" disabled={busyId === id}>
<Icon name="edit" size={12} />
</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === id}>
<Icon name="trash" size={12} />
</button>
{#each getChildren(null) as node (node.id)}
{#if node.type === 'folder'}
{@const expanded = isExpanded(node)}
<div class="wt-folder">
<div class="wt-row wt-folder-row" on:click={() => toggleFolder(node)}>
<span class="wt-chevron">{expanded ? '▾' : '▸'}</span>
<span class="wt-icon"><Icon name="folder" size={13} /></span>
<span class="wt-label wt-folder-label">{node.title}</span>
</div>
{#if expanded}
{#each getChildren(node.id) as child (child.id)}
<div class="wt-node vt-list-row" class:selected={child.id === $activeWorkspaceId} style="padding-left: 1.2rem;">
<div class="wt-row">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
<button class="wt-label" on:click={() => selectWorkspace(child)} type="button">{child.title}</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(child)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === child.id}>
<Icon name="trash" size={12} />
</button>
</div>
</div>
{/each}
{/if}
</div>
</div>
{:else}
<div class="wt-node vt-list-row" class:selected={node.id === $activeWorkspaceId}>
<div class="wt-row">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
<button class="wt-label" on:click={() => selectWorkspace(node)} type="button">{node.title}</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(node)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === node.id}>
<Icon name="trash" size={12} />
</button>
</div>
</div>
{/if}
{/each}
</div>
@ -396,15 +443,21 @@
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
<div class="workspace-create-modal">
<div class="workspace-create-header">
<div>
<h2>{tr('workspaceTree.new')}</h2>
</div>
<div><h2>{tr('workspaceTree.new')}</h2></div>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button>
</div>
<label class="workspace-create-field">
<span>{tr('pluginCard.name')}</span>
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder={tr('workspaceTree.namePlaceholder')} disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
</label>
<label class="workspace-create-field">
<span>{tr('workspaceTree.parentFolder') || 'Parent folder'}</span>
<select data-workspace-parent bind:value={parentFolderPath} disabled={creating}>
{#each parentFolderOptions as opt (opt.id)}
<option value={opt.id}>{opt.name}</option>
{/each}
</select>
</label>
<label class="workspace-create-field">
<span>{tr('workspaceTree.template')}</span>
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
@ -418,13 +471,7 @@
<p data-workspace-template-description>{selectedTemplate.description}</p>
<div class="workspace-template-tools" data-workspace-template-tools>
{#each selectedTemplateTools as tool (tool.pluginId)}
<div
class="workspace-template-tool"
class:limited={tool.status === 'limited'}
class:unavailable={tool.status === 'unavailable'}
data-workspace-template-tool={tool.pluginId}
data-template-tool-status={tool.status}
>
<div class="workspace-template-tool" class:limited={tool.status === 'limited'} class:unavailable={tool.status === 'unavailable'} data-workspace-template-tool={tool.pluginId} data-template-tool-status={tool.status}>
<span class="workspace-template-tool-name">{tool.name}</span>
<span class="workspace-template-tool-tabs">{tool.tabs.length ? tr('workspaceTree.templateToolTabs', { tabs: tool.tabs.join(', ') }) : tr('workspaceTree.templateToolNoTabs')}</span>
<span class="workspace-template-tool-reason">{tool.reason}</span>
@ -455,24 +502,23 @@
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0.6rem; }
.wt-btn { min-height: 1.55rem; background: transparent; border: 1px solid transparent; color: var(--vt-color-text-muted); cursor: pointer; font-size: 0.78rem; padding: 0.12rem 0.38rem; border-radius: var(--vt-radius-sm); }
.wt-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
.wt-always { opacity: 1; }
.wt-row:hover .wt-btn-small { opacity: 1; }
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: var(--vt-color-text-muted); }
.wt-error { color: var(--vt-color-danger); }
.wt-row { display: flex; align-items: center; gap: 0.45rem; padding: 0.18rem 0.45rem; min-height: 1.85rem; border-radius: var(--vt-radius-sm); }
.wt-row:hover { background: var(--vt-color-surface-hover); }
.wt-folder-row { cursor: pointer; }
.wt-folder-row:hover { color: var(--vt-color-accent); }
.wt-chevron { width: 0.8rem; font-size: 0.65rem; color: var(--vt-color-text-muted); flex-shrink: 0; user-select: none; }
.wt-folder-label { color: var(--vt-color-text-secondary); font-weight: 500; }
.wt-node.selected > .wt-row { background: var(--vt-color-surface-selected); box-shadow: inset 2px 0 0 var(--vt-color-accent); }
.wt-icon { width: 0.95rem; height: 0.95rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--vt-color-text-muted); }
:global(.wt-node-icon) { display: block; }
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: var(--vt-color-text-secondary); font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0; border-radius: var(--vt-radius-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: var(--vt-color-accent); }
.wt-icon-btn { width: 1.45rem; height: 1.45rem; min-height: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--vt-color-text-muted); opacity: 0.75; flex-shrink: 0; cursor: pointer; border-radius: var(--vt-radius-sm); }
.wt-icon-btn { width: 1.45rem; height: 1.45rem; min-height: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--vt-color-text-muted); opacity: 0; flex-shrink: 0; cursor: pointer; border-radius: var(--vt-radius-sm); }
.wt-row:hover .wt-icon-btn { opacity: 1; }
.wt-icon-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
.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); }
.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; }

View File

@ -979,6 +979,13 @@ type ContributionSummary struct {
FileActions []FlatAction `json:"fileActions"`
NoteActions []FlatAction `json:"noteActions"`
ContextMenuEntries []FlatContextMenuEntry `json:"contextMenuEntries"`
WorkspaceTree *FlatWorkspaceTree `json:"workspaceTree,omitempty"`
}
// FlatWorkspaceTree is the summary for the workspaceTree singleton contribution.
type FlatWorkspaceTree struct {
PluginID string `json:"pluginId"`
Component string `json:"component"`
}
// buildContributionSummary creates a ContributionSummary from the registry.
@ -1053,7 +1060,12 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
for i, v := range regContextMenus {
contextMenus[i] = FlatContextMenuEntry{PluginID: v.PluginID, ID: v.Item.ID, Label: v.Item.Label, Context: v.Item.Context, Group: v.Item.Group, Capability: v.Item.Capability, Handler: v.Item.Handler}
}
return ContributionSummary{Views: views, Commands: cmds, SearchProviders: searchProviders, SettingsPanels: panels, SidebarItems: sidebar, StatusBarItems: statusBarItems, OpenProviders: openProviders, WorkspaceItems: workspaceItems, FileActions: fileActions, NoteActions: noteActions, ContextMenuEntries: contextMenus}
var wsTree *FlatWorkspaceTree
regWSTree := r.WorkspaceTree()
if regWSTree != nil {
wsTree = &FlatWorkspaceTree{PluginID: regWSTree.PluginID, Component: regWSTree.Component}
}
return ContributionSummary{Views: views, Commands: cmds, SearchProviders: searchProviders, SettingsPanels: panels, SidebarItems: sidebar, StatusBarItems: statusBarItems, OpenProviders: openProviders, WorkspaceItems: workspaceItems, FileActions: fileActions, NoteActions: noteActions, ContextMenuEntries: contextMenus, WorkspaceTree: wsTree}
}
// GetContributions returns all registered contributions flattened for the frontend.
@ -2591,7 +2603,7 @@ func (a *App) UpdateWorkspaceMetadata(name string, patch workspace.MetadataPatch
return meta, ""
}
// GetCurrentWorkspace returns the currently selected top-level workspace.
// GetCurrentWorkspace returns the currently selected workspace.
func (a *App) GetCurrentWorkspace() map[string]interface{} {
if a.workspace == nil {
return map[string]interface{}{"status": "not initialized"}
@ -2600,7 +2612,7 @@ func (a *App) GetCurrentWorkspace() map[string]interface{} {
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
identity, err := a.workspace.GetWorkspaceIdentity(node.Name)
identity, err := a.workspace.GetWorkspaceIdentity(node.Path)
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
@ -2609,25 +2621,65 @@ func (a *App) GetCurrentWorkspace() map[string]interface{} {
"workspaceId": identity.WorkspaceID,
"name": node.Name,
"rootPath": node.RootPath,
"path": node.Path,
}
}
// SetCurrentWorkspace stores the selected top-level workspace name as UI state.
func (a *App) SetCurrentWorkspace(name string) string {
// SetCurrentWorkspace stores the selected workspace path as UI state.
func (a *App) SetCurrentWorkspace(path string) string {
if a.workspace == nil {
return "workspace not initialized"
}
if err := a.workspace.SetCurrentNode(name); err != nil {
if err := a.workspace.SetCurrentNode(path); err != nil {
return err.Error()
}
node, err := a.workspace.GetCurrentNode()
if err != nil {
return err.Error()
}
a.publishWorkspaceLifecycleEvent(workspaceSelectedEventName, map[string]interface{}{
"operation": "select",
"workspaceRootPath": name,
"workspaceName": name,
"workspaceRootPath": node.RootPath,
"workspaceName": node.Name,
"workspacePath": node.Path,
})
return ""
}
// GetFolderMetadata returns stored metadata for a plain vault folder.
func (a *App) GetFolderMetadata(path string) (workspace.FolderMetadata, string) {
if a.workspace == nil {
return workspace.FolderMetadata{}, "workspace not initialized"
}
meta, err := a.workspace.GetFolderMetadata(path)
if err != nil {
return workspace.FolderMetadata{}, err.Error()
}
return meta, ""
}
// SetFolderMetadata updates metadata for a plain vault folder.
func (a *App) SetFolderMetadata(path string, meta workspace.FolderMetadata) string {
if a.workspace == nil {
return "workspace not initialized"
}
if err := a.workspace.SetFolderMetadata(path, meta); err != nil {
return err.Error()
}
return ""
}
// MoveWorkspace moves a workspace to another parent folder.
func (a *App) MoveWorkspace(id, newParentID string) string {
if a.workspace == nil {
return "workspace not initialized"
}
if err := a.workspace.MoveNode(id, newParentID); err != nil {
return err.Error()
}
return ""
}
// Deprecated: compatibility wrapper over the flat top-level folder workspace
// model. Prefer ListWorkspaces.
func (a *App) GetWorkspaceTree() map[string]interface{} {

View File

@ -2624,9 +2624,6 @@ func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
if len(nodes) == 0 {
t.Fatal("workspace nodes should not be empty")
}
if nodes[0].Path != "" {
t.Fatalf("compatibility node should not expose workspace path mapping: %+v", nodes[0])
}
if !app.capRegistry.Has("verstak/core/workspace/v1") {
t.Fatal("workspace capability should be registered after SetCurrentVault")
}
@ -2905,11 +2902,11 @@ func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) {
}
errStr := app.MoveWorkspaceNode("Project", "Test")
if errStr == "" || !strings.Contains(errStr, "top-level only") {
t.Fatalf("MoveWorkspaceNode error = %q, want top-level only", errStr)
if errStr == "" || !strings.Contains(errStr, "parent-is-workspace") {
t.Fatalf("MoveWorkspaceNode error = %q, want parent-is-workspace", errStr)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(err) {
t.Fatalf("MoveWorkspaceNode created nested mapped workspace, stat err=%v", err)
t.Fatalf("MoveWorkspaceNode created nested workspace inside another workspace, stat err=%v", err)
}
}

View File

@ -24,6 +24,13 @@ type Registry struct {
statusBarItems []ContributionStatusBarItem
openProviders []ContributionOpenProvider
workspaceItems []ContributionWorkspaceItem
workspaceTree *ContributionWorkspaceTree
}
// ContributionWorkspaceTree is a singleton contribution for replacing the Deal tree.
type ContributionWorkspaceTree struct {
PluginID string `json:"pluginId"`
Component string `json:"component"`
}
// ContributionPointType defines the type of contribution point.
@ -42,6 +49,7 @@ const (
PointStatusBar ContributionPointType = "statusBarItems"
PointOpenProviders ContributionPointType = "openProviders"
PointWorkspaceItems ContributionPointType = "workspaceItems"
PointWorkspaceTree ContributionPointType = "workspaceTree"
)
// ListByPoint returns all contributions for a given point type.
@ -184,6 +192,7 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
r.workspaceTree = nil
for _, item := range c.Views {
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
@ -221,6 +230,9 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
for _, item := range c.WorkspaceItems {
r.workspaceItems = append(r.workspaceItems, ContributionWorkspaceItem{PluginID: pluginID, Item: item})
}
if c.WorkspaceTree != nil && r.workspaceTree == nil {
r.workspaceTree = &ContributionWorkspaceTree{PluginID: pluginID, Component: c.WorkspaceTree.Component}
}
}
// Unregister removes all contributions from a plugin.
@ -240,6 +252,9 @@ func (r *Registry) Unregister(pluginID string) {
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
if r.workspaceTree != nil && r.workspaceTree.PluginID == pluginID {
r.workspaceTree = nil
}
}
// Getters — sorted for deterministic display.
@ -367,6 +382,13 @@ func (r *Registry) WorkspaceItems() []ContributionWorkspaceItem {
return result
}
// WorkspaceTree returns the singleton workspaceTree contribution, or nil.
func (r *Registry) WorkspaceTree() *ContributionWorkspaceTree {
r.mu.RLock()
defer r.mu.RUnlock()
return r.workspaceTree
}
// ─── Remove helpers ─────────────────────────────────────────
func removeViews(items []ContributionView, pluginID string) []ContributionView {

View File

@ -77,6 +77,12 @@ type Contributions struct {
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
WorkspaceItems []ContributionWorkspaceItem `json:"workspaceItems,omitempty"`
WorkspaceTree *ContributionWorkspaceTree `json:"workspaceTree,omitempty"`
}
// ContributionWorkspaceTree represents a singleton workspaceTree contribution.
type ContributionWorkspaceTree struct {
Component string `json:"component"`
}
// ContributionView represents a view contribution.

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,9 @@ import (
func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Test"))
mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
mustMkdir(t, filepath.Join(vaultDir, ".git"))
mustWrite(t, filepath.Join(vaultDir, "readme.md"), "not a workspace")
@ -36,6 +38,61 @@ func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
}
}
func TestListWorkspacesIncludesNestedWorkspaces(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Clients"))
mustMkdir(t, filepath.Join(vaultDir, "Clients", "Romashka"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Clients", "Romashka"))
mustMkdir(t, filepath.Join(vaultDir, "Clients", "Alpha"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Clients", "Alpha"))
mustMkdir(t, filepath.Join(vaultDir, "Personal"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Personal"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 3 {
t.Fatalf("workspaces = %d, want 3", len(workspaces))
}
paths := make([]string, len(workspaces))
for i, ws := range workspaces {
paths[i] = ws.Path
}
wantPaths := []string{"Clients/Alpha", "Clients/Romashka", "Personal"}
if strings.Join(paths, ",") != strings.Join(wantPaths, ",") {
t.Fatalf("paths = %v, want %v", paths, wantPaths)
}
}
func TestCreateWorkspaceNested(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
// Create parent folder first
mustMkdir(t, filepath.Join(vaultDir, "Clients"))
ws, err := m.CreateWorkspace("Clients/Project", "")
if err != nil {
t.Fatalf("CreateWorkspace nested: %v", err)
}
if ws.Path != "Clients/Project" {
t.Fatalf("workspace path = %q, want Clients/Project", ws.Path)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Clients", "Project")); err != nil {
t.Fatalf("workspace folder missing: %v", err)
}
// Verify marker
if _, err := os.Stat(filepath.Join(vaultDir, "Clients", "Project", ".verstak", "workspace.json")); err != nil {
t.Fatalf("marker missing: %v", err)
}
}
func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation needs extra privileges on Windows")
@ -556,23 +613,24 @@ func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
}
}
func TestInvalidWorkspaceNamesRejected(t *testing.T) {
func TestInvalidWorkspacePathsRejected(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
names := []string{"", " ", "A/B", `A\B`, "/abs", `C:\abs`, "..", "a..b", "bad\x00name", ".verstak", ".Verstak", ".git"}
for _, name := range names {
if _, err := m.CreateWorkspace(name, ""); err == nil {
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid name error", name)
paths := []string{"", " ", `A\B`, "/abs", `C:\abs`, "..", "a/../b", "bad\x00name", ".verstak", ".Verstak", ".git"}
for _, path := range paths {
if _, err := m.CreateWorkspace(path, ""); err == nil {
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid path error", path)
}
}
}
func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
func TestCompatibilityTreeIncludesFoldersAndNestedWorkspaces(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Project", "Nested"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
@ -582,34 +640,43 @@ func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
if len(tree.Nodes) != 2 {
t.Fatalf("nodes = %+v, want 2 top-level workspaces", tree.Nodes)
}
if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" || tree.Nodes[0].Path != "" {
t.Fatalf("first compatibility node = %+v, want derived workspace without persisted path mapping", tree.Nodes[0])
if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" {
t.Fatalf("first compatibility node = %+v, want workspace node", tree.Nodes[0])
}
for _, node := range tree.Nodes {
if node.ParentID != "" {
t.Fatalf("compatibility tree should be flat, got child node %+v", node)
}
if node.ID == "Nested" || node.Title == "Nested" {
t.Fatalf("nested folders must not become workspace nodes: %+v", tree.Nodes)
t.Fatalf("nested folders without markers must not become workspace nodes: %+v", tree.Nodes)
}
}
// Workspace nodes should NOT have ParentID for root-level ones
for _, node := range tree.Nodes {
if node.Type == TypeSpace && node.ID == "Project" && node.ParentID != "" {
t.Fatalf("root workspace should have empty ParentID: %+v", node)
}
}
}
func TestMoveNodeCompatibilityDoesNotCreateNestedWorkspaceModel(t *testing.T) {
func TestMoveNodeCreatesNestedWorkspaceModel(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
// Move workspace into Test folder (which is a plain folder without marker)
err := m.MoveNode("Project", "Test")
if err == nil || !strings.Contains(err.Error(), "top-level only") {
t.Fatalf("MoveNode error = %v, want top-level only", err)
if err != nil {
t.Fatalf("MoveNode error = %v, want success", err)
}
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(statErr) {
t.Fatalf("MoveNode created nested mapped workspace, stat err=%v", statErr)
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); statErr != nil {
t.Fatalf("MoveNode did not create nested folder, stat err=%v", statErr)
}
// Verify marker moved
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project", ".verstak", "workspace.json")); statErr != nil {
t.Fatalf("marker not in new location: %v", statErr)
}
}
@ -636,6 +703,22 @@ func TestMetadataFileShape(t *testing.T) {
}
}
func mustWriteWorkspaceMarker(t *testing.T, dir string) {
t.Helper()
markerPath := filepath.Join(dir, ".verstak", "workspace.json")
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(markerPath), err)
}
marker := workspaceIdentityMarker{WorkspaceID: uuid.NewString()}
data, err := json.Marshal(marker)
if err != nil {
t.Fatalf("marshal marker: %v", err)
}
if err := os.WriteFile(markerPath, data, 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", markerPath, err)
}
}
func newVaultDir(t *testing.T) string {
t.Helper()
vaultDir := filepath.Join(t.TempDir(), "vault")

View File

@ -0,0 +1,39 @@
## Highlights
- **Nested workspaces.** Deals can now live at any depth inside plain vault
folders. Identity stays with the `.verstak/workspace.json` UUID marker;
the path is just an address.
- **Folder metadata.** Plain vault folders can carry an icon, color, and
display order through `.verstak/folder-metadata/`.
- **Plugin-replaceable Deal tree.** A new `workspaceTree` contribution point
lets plugins replace the sidebar Deal tree entirely. The included
`verstak.workspace-folders` official plugin adds folder icons, color,
draganddrop, a full IconPicker (1703 Lucide icons), and a color picker.
- **Move workspaces.** `MoveWorkspace` moves a Deal between plain folders.
- **Plugin API:** `api.workspaces.*` and `api.folders.*` are now available to
bundled frontend plugins.
- Documentation was pruned and aligned with the current implementation state.
## Главное
- **Вложенные Дела.** Дела теперь можно размещать на любой глубине внутри
обычных папок vault. Идентичность хранится в UUID-маркере
`.verstak/workspace.json`; путь — это только адрес.
- **Метаданные папок.** Обычные папки vault могут иметь иконку, цвет и порядок
отображения через `.verstak/folder-metadata/`.
- **Заменяемое дерево Дел.** Новый contribution point `workspaceTree` позволяет
плагинам полностью заменить дерево Дел в сайдбаре. Включённый официальный
плагин `verstak.workspace-folders` добавляет иконки папок, цвета,
draganddrop, полный IconPicker (1703 иконки Lucide) и выбор цвета.
- **Перемещение Дел.** `MoveWorkspace` перемещает Дело между обычными папками.
- **Plugin API:** методы `api.workspaces.*` и `api.folders.*` доступны
bundled frontend-плагинам.
- Документация почищена и приведена в соответствие с текущим состоянием.
## Packages / Пакеты
This prerelease provides a portable Windows archive, a Debian package, and an
AppImage for Linux.
В этот prerelease входят переносимый архив для Windows, Debian-пакет и AppImage
для Linux.