fix: polish workspace files and editor shell

This commit is contained in:
mirivlad 2026-06-19 23:37:10 +08:00
parent a6412fa070
commit 5c979174f1
23 changed files with 497 additions and 249 deletions

View File

@ -54,9 +54,11 @@ Canonical rules:
Canonical scoped paths: Canonical scoped paths:
- Workspace/root overview notes live under `Notes/`. - Workspace/root overview notes live under `<workspace-node-path>/Notes/`.
- Case/project/folder scoped notes live under `<case-or-parent>/Notes/`. - Case/project/folder scoped notes live under `<workspace-node-path>/Notes/`.
- The default overview note is `<case-or-parent>/Notes/Overview.md`. - The default overview note is `<workspace-node-path>/Notes/Overview.md`.
- `workspace-node-path` is a normal vault-relative folder path stored on the
workspace node. Files plugin workspace views are scoped to this path.
Visibility requirements: Visibility requirements:

View File

@ -146,7 +146,7 @@ foreach plugin:
|---|---|---| |---|---|---|
| `description` | string | Описание плагина | | `description` | string | Описание плагина |
| `source` | string | `"official"`, `"local"`, `"third-party"` | | `source` | string | `"official"`, `"local"`, `"third-party"` |
| `icon` | string | Иконка (emoji или имя) | | `icon` | string | Имя иконки из встроенного Lucide-набора shell |
| `requires` | string[] | Жёзкие capability-зависимости | | `requires` | string[] | Жёзкие capability-зависимости |
| `optionalRequires` | string[] | Мягкие capability-зависимости | | `optionalRequires` | string[] | Мягкие capability-зависимости |
| `frontend` | object | `{ "entry": "path/to/index.js", "style": "path/to/style.css" }` | | `frontend` | object | `{ "entry": "path/to/index.js", "style": "path/to/style.css" }` |
@ -180,6 +180,8 @@ foreach plugin:
Плагины могут регистрировать UI contributions через поле `contributes` в `plugin.json`. Плагины могут регистрировать UI contributions через поле `contributes` в `plugin.json`.
Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper. Plugins must not rely on emoji, Unicode pictographs, or system icon fonts; if a plugin needs its own icon font, it must bundle the font and reference it from its own frontend bundle.
### Реализованные contribution points (Milestone 5a) ### Реализованные contribution points (Milestone 5a)
| Тип | Поле manifest | Описание | Frontend host | | Тип | Поле manifest | Описание | Frontend host |
@ -210,7 +212,7 @@ foreach plugin:
{ {
"id": "mypanel.sidebar", "id": "mypanel.sidebar",
"title": "My Panel", "title": "My Panel",
"icon": "📌", "icon": "puzzle",
"view": "mypanel.view", "view": "mypanel.view",
"position": 100 "position": 100
} }
@ -219,7 +221,7 @@ foreach plugin:
{ {
"id": "mypanel.view", "id": "mypanel.view",
"title": "My Panel View", "title": "My Panel View",
"icon": "📌", "icon": "puzzle",
"component": "MyPanelComponent" "component": "MyPanelComponent"
} }
], ],
@ -234,7 +236,7 @@ foreach plugin:
{ {
"id": "mypanel.cmd", "id": "mypanel.cmd",
"title": "Do Something", "title": "Do Something",
"icon": "", "icon": "gear",
"handler": "doSomething" "handler": "doSomething"
} }
] ]
@ -717,12 +719,13 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
│ │ │ │ │ │ │ │ │ │
│ │ Verstak │ PluginManager | ViewContainer │ │ │ │ Verstak │ PluginManager | ViewContainer │ │
│ │ │ │ │ │ │ │ │ │
│ │ 🧩 Plugin│ (padding: 1.5rem) │ │ │ │ [icon] │ (padding: 1.5rem) │ │
│ │ Plugin │ │ │
│ │ Manager│ │ │ │ │ Manager│ │ │
│ │ │ │ │ │ │ │ │ │
│ │ Plugins │ │ │ │ │ Plugins │ │ │
│ │ 📌 item1 │ │ │ │ │ [icon] item1 │ │
│ │ 📌 item2 │ │ │ │ │ [icon] item2 │ │
│ │ │ │ │ │ │ │ │ │
│ │ ● Vault │ │ │ │ │ ● Vault │ │ │
│ └──────────┴──────────────────────────────────────┘ │ │ └──────────┴──────────────────────────────────────┘ │
@ -759,6 +762,7 @@ Workspace — центральная модель Верстака вокруг
| `parentId` | string | ID родителя (пусто для root) | | `parentId` | string | ID родителя (пусто для root) |
| `type` | space/case/folder | Тип ноды | | `type` | space/case/folder | Тип ноды |
| `title` | string | Название | | `title` | string | Название |
| `path` | string | Vault-relative папка ноды |
| `status` | active/sleeping/archived | Жизненный цикл | | `status` | active/sleeping/archived | Жизненный цикл |
| `tags` | string[] | Теги | | `tags` | string[] | Теги |
| `order` | int | Порядок среди siblings | | `order` | int | Порядок среди siblings |
@ -767,7 +771,20 @@ Workspace — центральная модель Верстака вокруг
### Хранение ### Хранение
`<vault>/.verstak/workspace.json` — атомарная запись (temp + rename). `<vault>/.verstak/workspace.json` — атомарная запись metadata (temp + rename).
Каждая workspace node также имеет user-visible folder inside vault. `path`
хранит canonical vault-relative folder path. Имена папок читаемые: берутся из
title, очищаются от запрещённых символов, сохраняют Unicode/кириллицу, а при
коллизии получают suffix ` (2)`, ` (3)`, ...
Пример:
```
<vault>/
My Workspace/
Test/
test/
```
### API ### API
@ -786,8 +803,15 @@ Workspace — центральная модель Верстака вокруг
### Правила ### Правила
- Root node создаётся при создании vault - Root node создаётся при создании vault
- Для каждой node создаётся обычная папка внутри vault
- WorkspaceItems получают выбранную node и `workspaceRootPath`; Files plugin
показывает именно эту папку, а не общий root vault
- Порядок children стабилен (sort by order) - Порядок children стабилен (sort by order)
- Нельзя переместить ноду в себя или в своего потомка - Нельзя переместить ноду в себя или в своего потомка
- `MoveWorkspaceNode` переносит physical folder subtree and updates descendant
paths
- `RenameWorkspaceNode` меняет display title; physical folder rename/UI для этого
остаётся отдельным действием
- Архивирование — soft delete (status = archived) - Архивирование — soft delete (status = archived)
- Corrupt JSON → backup + defaults - Corrupt JSON → backup + defaults

View File

@ -27,6 +27,19 @@ test.describe('G: Files Plugin', () => {
await expect(sidebarItem).toHaveCount(0); await expect(sidebarItem).toHaveCount(0);
}); });
test('workspace Files view is scoped to selected workspace folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Alpha Case' }).click();
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'alpha-only.txt' })).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'beta-only.txt' })).toHaveCount(0);
await page.locator('.wt-label').filter({ hasText: 'Beta Case' }).click();
await expect(page.locator('.files-item-name').filter({ hasText: 'beta-only.txt' })).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'alpha-only.txt' })).toHaveCount(0);
});
test('open .txt via workbench from files context shows default-editor', async ({ page }) => { test('open .txt via workbench from files context shows default-editor', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', { const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
@ -42,6 +55,11 @@ test.describe('G: Files Plugin', () => {
const editor = page.locator('[data-editor-mode="text"]'); const editor = page.locator('[data-editor-mode="text"]');
await expect(editor).toBeVisible({ timeout: 10000 }); await expect(editor).toBeVisible({ timeout: 10000 });
await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt'); await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
const textarea = page.locator('.de-textarea');
await expect(textarea).toBeVisible({ timeout: 10000 });
const textareaBox = await textarea.boundingBox();
expect(textareaBox.height).toBeGreaterThan(300);
}); });
test('open .md via workbench from files context shows generic-markdown', async ({ page }) => { test('open .md via workbench from files context shows generic-markdown', async ({ page }) => {
@ -59,6 +77,11 @@ test.describe('G: Files Plugin', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Docs/readme.md'); await expect(workbench.locator('.workbench-title')).toHaveText('Docs/readme.md');
const preview = page.locator('.de-preview');
await expect(preview).toBeVisible({ timeout: 10000 });
const previewBox = await preview.boundingBox();
expect(previewBox.height).toBeGreaterThan(300);
}); });
test('open notes markdown via workbench from files context shows notes-markdown', async ({ page }) => { test('open notes markdown via workbench from files context shows notes-markdown', async ({ page }) => {

View File

@ -81,4 +81,15 @@ test.describe('E: Plugin Manager layout', () => {
await expect(selected).toHaveCount(1); await expect(selected).toHaveCount(1);
await expect(selected).toHaveText('Beta Case'); await expect(selected).toHaveText('Beta Case');
}); });
test('shell icons render through bundled Lucide SVG components', async ({ page }) => {
const logo = page.locator('.sidebar-logo');
await expect(logo).toBeVisible();
await expect(logo).toHaveClass(/lucide/);
await page.locator('.wt-label').filter({ hasText: 'Alpha Case' }).click();
const workspaceIcon = page.locator('.wt-node-icon').first();
await expect(workspaceIcon).toBeVisible();
await expect(workspaceIcon).toHaveClass(/lucide/);
});
}); });

View File

@ -10,6 +10,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.0", "@playwright/test": "^1.61.0",
"@sveltejs/vite-plugin-svelte": "^3.1.0", "@sveltejs/vite-plugin-svelte": "^3.1.0",
"lucide-svelte": "^0.577.0",
"svelte": "^4.2.0", "svelte": "^4.2.0",
"vite": "^5.4.0" "vite": "^5.4.0"
} }
@ -982,6 +983,16 @@
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true "dev": true
}, },
"node_modules/lucide-svelte": {
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
"deprecated": "Package deprecated. Please use @lucide/svelte instead.",
"dev": true,
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@ -14,6 +14,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.0", "@playwright/test": "^1.61.0",
"@sveltejs/vite-plugin-svelte": "^3.1.0", "@sveltejs/vite-plugin-svelte": "^3.1.0",
"lucide-svelte": "^0.577.0",
"svelte": "^4.2.0", "svelte": "^4.2.0",
"vite": "^5.4.0" "vite": "^5.4.0"
} }

View File

@ -1 +1 @@
43be2fbdf6ba6ca9504a7c4b0ac32ae0 c02aa6458981565332c3b0bc49516b42

View File

@ -17,13 +17,15 @@
let currentPluginId = null; let currentPluginId = null;
let currentComponent = null; let currentComponent = null;
let currentAPI = null; let currentAPI = null;
let currentPropsKey = '';
$: activePluginId = pluginId || viewPluginId; $: activePluginId = pluginId || viewPluginId;
$: activeComponent = componentId; $: activeComponent = componentId;
$: propsKey = JSON.stringify(componentProps || {});
// React to changes — reload on view change // React to changes — reload on view change
$: if (activePluginId && activeComponent) { $: if (activePluginId && activeComponent) {
loadAndMount(activePluginId, activeComponent); loadAndMount(activePluginId, activeComponent, propsKey);
} else if (!activePluginId) { } else if (!activePluginId) {
cleanup(); cleanup();
loadState = 'idle'; loadState = 'idle';
@ -58,6 +60,7 @@
currentPluginId = null; currentPluginId = null;
currentComponent = null; currentComponent = null;
currentAPI = null; currentAPI = null;
currentPropsKey = '';
} }
function unpackBackendResult(result) { function unpackBackendResult(result) {
@ -67,9 +70,9 @@
return { value: result, error: '' }; return { value: result, error: '' };
} }
async function loadAndMount(pId, compId) { async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip // If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && loadState === 'loaded') { if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
return; return;
} }
@ -80,6 +83,7 @@
errorText = ''; errorText = '';
currentPluginId = pId; currentPluginId = pId;
currentComponent = compId; currentComponent = compId;
currentPropsKey = nextPropsKey;
try { try {
// Get plugin frontend info // Get plugin frontend info
@ -181,7 +185,7 @@
{:else if loadState === 'error'} {:else if loadState === 'error'}
<div class="host-state error"> <div class="host-state error">
<Icon name="warning" size={24} className="error-icon" /> <Icon name="warning" size={24} class="error-icon" />
<p class="error-title">Plugin View Error</p> <p class="error-title">Plugin View Error</p>
<div class="error-details"> <div class="error-details">
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p> <p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
@ -209,17 +213,20 @@
bind:this={mountContainer} bind:this={mountContainer}
data-plugin-id={currentPluginId} data-plugin-id={currentPluginId}
data-component={currentComponent} data-component={currentComponent}
style="flex:1;min-width:0;min-height:0;height:100%;display:flex;flex-direction:column;position:relative;"
></div> ></div>
{/if} {/if}
</div> </div>
<style> <style>
.plugin-bundle-host { .plugin-bundle-host {
flex: 1;
width: 100%; width: 100%;
min-height: 200px; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0;
} }
.host-state { .host-state {
@ -305,7 +312,12 @@
} }
.plugin-mount-container { .plugin-mount-container {
flex: 1;
min-width: 0; min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
position: relative; position: relative;
} }

View File

@ -166,7 +166,7 @@
{@const isDangerous = dangerousPermissions.includes(perm)} {@const isDangerous = dangerousPermissions.includes(perm)}
<span class="tag" class:dangerous={isDangerous}> <span class="tag" class:dangerous={isDangerous}>
{perm} {perm}
{#if isDangerous}<Icon name="warning" size={12} className="danger-icon" />{/if} {#if isDangerous}<Icon name="warning" size={12} class="danger-icon" />{/if}
</span> </span>
{/each} {/each}
</div> </div>
@ -362,7 +362,7 @@
} }
.check { color: #4ecca3; margin-left: 2px; } .check { color: #4ecca3; margin-left: 2px; }
.danger-icon { color: #e94560; margin-left: 2px; vertical-align: middle; } :global(.danger-icon) { color: #e94560; margin-left: 2px; vertical-align: middle; }
.info { .info {
color: #ffc857; color: #ffc857;

View File

@ -268,7 +268,7 @@
<div class="loading">Scanning plugin directories...</div> <div class="loading">Scanning plugin directories...</div>
{:else if error} {:else if error}
<div class="error"> <div class="error">
<Icon name="warning" size={24} className="error-icon" /> <Icon name="warning" size={24} class="error-icon" />
<div class="error-message">{error}</div> <div class="error-message">{error}</div>
<button class="retry-btn" on:click={loadAll} type="button">⟳ Retry</button> <button class="retry-btn" on:click={loadAll} type="button">⟳ Retry</button>
</div> </div>
@ -454,7 +454,7 @@
padding: 2rem; text-align: center; color: #a0a0b8; padding: 2rem; text-align: center; color: #a0a0b8;
} }
.error { color: #e94560; } .error { color: #e94560; }
.error-icon { color: #e94560; margin-bottom: 0.5rem; } :global(.error-icon) { color: #e94560; margin-bottom: 0.5rem; }
.error-message { .error-message {
font-family: monospace; font-size: 0.85rem; margin-bottom: 1rem; word-break: break-word; font-family: monospace; font-size: 0.85rem; margin-bottom: 1rem; word-break: break-word;
} }

View File

@ -81,7 +81,7 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<Icon name="logo" size={20} className="sidebar-logo" /> <Icon name="logo" size={20} class="sidebar-logo" />
<span class="sidebar-title">Verstak</span> <span class="sidebar-title">Verstak</span>
</div> </div>
@ -92,7 +92,7 @@
on:click={() => handleNav(item.id)} on:click={() => handleNav(item.id)}
type="button" type="button"
> >
<Icon name={item.icon} size={16} className="nav-icon" /> <Icon name={item.icon} size={16} class="nav-icon" />
<span class="nav-label">{item.label}</span> <span class="nav-label">{item.label}</span>
</button> </button>
{/each} {/each}
@ -107,7 +107,7 @@
on:click={() => handleSidebarItem(item)} on:click={() => handleSidebarItem(item)}
type="button" type="button"
> >
<Icon name={item.icon || 'plugin'} size={16} className="nav-icon icon-plugin" /> <Icon name={item.icon || 'plugin'} size={16} class="nav-icon icon-plugin" />
<span class="nav-label">{item.title || item.id}</span> <span class="nav-label">{item.title || item.id}</span>
</button> </button>
{/each} {/each}
@ -121,7 +121,7 @@
<div class="sidebar-footer"> <div class="sidebar-footer">
{#if errorMessage} {#if errorMessage}
<span class="sidebar-error"> <span class="sidebar-error">
<Icon name="warning" size={10} className="sidebar-error-icon" /> <Icon name="warning" size={10} class="sidebar-error-icon" />
Plugin UI error Plugin UI error
</span> </span>
{/if} {/if}
@ -152,7 +152,7 @@
border-bottom: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
} }
.sidebar-logo { :global(.sidebar-logo) {
width: 1.2rem; width: 1.2rem;
height: 1.2rem; height: 1.2rem;
color: #4ecca3; color: #4ecca3;
@ -218,13 +218,13 @@
color: #e0e0f0; color: #e0e0f0;
} }
.nav-icon { :global(.nav-icon) {
width: 1.2rem; width: 1.2rem;
height: 1.2rem; height: 1.2rem;
flex-shrink: 0; flex-shrink: 0;
color: currentColor; color: currentColor;
} }
.nav-icon.icon-plugin { :global(.nav-icon.icon-plugin) {
color: #a78bfa; color: #a78bfa;
} }
@ -261,7 +261,7 @@
color: #e94560; color: #e94560;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.sidebar-error-icon { :global(.sidebar-error-icon) {
color: #e94560; color: #e94560;
} }
</style> </style>

View File

@ -135,7 +135,7 @@
{#if error} {#if error}
<div class="error-box"> <div class="error-box">
<Icon name="warning" size={14} className="error-icon" /> <Icon name="warning" size={14} class="error-icon" />
<span class="error-text">{error}</span> <span class="error-text">{error}</span>
</div> </div>
{/if} {/if}
@ -191,7 +191,7 @@
{#each recentVaults as path} {#each recentVaults as path}
<li> <li>
<button class="recent-item" on:click={() => openRecent(path)} type="button" disabled={opening}> <button class="recent-item" on:click={() => openRecent(path)} type="button" disabled={opening}>
<Icon name="vault" size={16} className="recent-icon" /> <Icon name="vault" size={16} class="recent-icon" />
<span class="recent-path">{path}</span> <span class="recent-path">{path}</span>
</button> </button>
</li> </li>
@ -247,7 +247,7 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #e94560; color: #e94560;
} }
.error-icon { flex-shrink: 0; } :global(.error-icon) { flex-shrink: 0; }
.error-text { word-break: break-word; } .error-text { word-break: break-word; }
.actions { .actions {
display: flex; display: flex;
@ -369,7 +369,7 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.recent-icon { flex-shrink: 0; } :global(.recent-icon) { flex-shrink: 0; }
.recent-path { .recent-path {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

View File

@ -48,7 +48,7 @@
<div class="view-container"> <div class="view-container">
<div class="error-boundary"> <div class="error-boundary">
<div class="error-fallback"> <div class="error-fallback">
<Icon name="warning" size={24} className="error-icon" /> <Icon name="warning" size={24} class="error-icon" />
<p class="error-title">Plugin UI failed</p> <p class="error-title">Plugin UI failed</p>
<p class="error-text">{renderError}</p> <p class="error-text">{renderError}</p>
</div> </div>
@ -58,7 +58,7 @@
<div class="view-container"> <div class="view-container">
<div class="view" class:degraded={pluginStatus === 'degraded'}> <div class="view" class:degraded={pluginStatus === 'degraded'}>
<div class="view-header"> <div class="view-header">
<Icon name={currentView.icon || 'logo'} size={20} className="view-icon" /> <Icon name={currentView.icon || 'logo'} size={20} class="view-icon" />
<h2>{currentView.title}</h2> <h2>{currentView.title}</h2>
{#if hasFrontend} {#if hasFrontend}
<span class="frontend-badge">frontend bundle</span> <span class="frontend-badge">frontend bundle</span>
@ -128,7 +128,7 @@
color: #e0e0f0; color: #e0e0f0;
flex: 1; flex: 1;
} }
.view-icon { :global(.view-icon) {
width: 1.3rem; width: 1.3rem;
height: 1.3rem; height: 1.3rem;
color: #a78bfa; color: #a78bfa;
@ -212,7 +212,7 @@
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
} }
.error-icon { :global(.error-icon) {
color: #e94560; color: #e94560;
} }
.error-title { .error-title {

View File

@ -54,8 +54,10 @@
<style> <style>
.workbench-host { .workbench-host {
flex: 1;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #1a1a2e; background: #1a1a2e;

View File

@ -51,7 +51,7 @@
<PluginBundleHost <PluginBundleHost
pluginId={tool.pluginId} pluginId={tool.pluginId}
componentId={tool.component} componentId={tool.component}
componentProps={{ workspaceNodeId: currentNodeId, workspaceNode: currentNode }} componentProps={{ workspaceNodeId: currentNodeId, workspaceNode: currentNode, workspaceRootPath: currentNode.path || '' }}
/> />
</div> </div>
</div> </div>

View File

@ -7,6 +7,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
export let nodes = []; export let nodes = [];
export let node = null; export let node = null;
@ -66,11 +67,11 @@
return nodes.some(n => n.parentId === id); return nodes.some(n => n.parentId === id);
} }
function icon(type) { function iconName(type) {
if (type === 'space') return '\u{1F310}'; if (type === 'space') return 'space';
if (type === 'case') return '\u{1F4CB}'; if (type === 'case') return 'case';
if (type === 'folder') return '\u{1F4C1}'; if (type === 'folder') return 'folder';
return '\u{1F4C4}'; return 'dot';
} }
async function selectNode(id) { async function selectNode(id) {
@ -121,7 +122,7 @@
<div class="wt-error">{localError}</div> <div class="wt-error">{localError}</div>
{:else} {:else}
{#each roots() as node (node.id)} {#each roots() as node (node.id)}
<svelte:self {node} {nodes} {currentNodeId} {expandedNodes} depth={1} {icon} {toggle} {hasKids} {selectNode} {openCreate} /> <svelte:self {node} {nodes} {currentNodeId} {expandedNodes} depth={1} {toggle} {hasKids} {selectNode} {openCreate} />
{/each} {/each}
{/if} {/if}
@ -129,7 +130,7 @@
<div class="wt-create"> <div class="wt-create">
<div class="wt-create-header"> <div class="wt-create-header">
<span>New {newNodeType}</span> <span>New {newNodeType}</span>
<button class="wt-btn" on:click={cancelCreate} type="button">\u2715</button> <button class="wt-btn" on:click={cancelCreate} type="button">x</button>
</div> </div>
<input type="text" bind:value={newNodeTitle} placeholder="Name..." disabled={creating} /> <input type="text" bind:value={newNodeTitle} placeholder="Name..." disabled={creating} />
<div class="wt-create-actions"> <div class="wt-create-actions">
@ -143,11 +144,13 @@
<div class="wt-node" class:selected={node.id === $activeWorkspaceNodeId} class:archived={node.status === 'archived'} class:sleeping={node.status === 'sleeping'}> <div class="wt-node" class:selected={node.id === $activeWorkspaceNodeId} class:archived={node.status === 'archived'} class:sleeping={node.status === 'sleeping'}>
<div class="wt-row" style="padding-left: {depth * 1.0 + 0.4}rem;"> <div class="wt-row" style="padding-left: {depth * 1.0 + 0.4}rem;">
{#if hasKids(node.id)} {#if hasKids(node.id)}
<button class="wt-expand" on:click={() => toggle(node.id)} type="button">{expandedNodes[node.id] ? '\u25BE' : '\u25B8'}</button> <button class="wt-expand" on:click={() => toggle(node.id)} type="button" aria-label={expandedNodes[node.id] ? 'Collapse' : 'Expand'}>
<Icon name={expandedNodes[node.id] ? 'chevronDown' : 'chevronRight'} size={12} class="wt-expand-icon" />
</button>
{:else} {:else}
<span class="wt-expand-spacer"></span> <span class="wt-expand-spacer"></span>
{/if} {/if}
<span class="wt-icon">{icon(node.type)}</span> <span class="wt-icon"><Icon name={iconName(node.type)} size={13} class="wt-node-icon" /></span>
<button class="wt-label" on:click={() => selectNode(node.id)} type="button">{node.title}</button> <button class="wt-label" on:click={() => selectNode(node.id)} type="button">{node.title}</button>
{#if node.type !== 'case'} {#if node.type !== 'case'}
<button class="wt-btn wt-btn-small" on:click={() => openCreate(node.id, 'case')} title="Add child" type="button">+</button> <button class="wt-btn wt-btn-small" on:click={() => openCreate(node.id, 'case')} title="Add child" type="button">+</button>
@ -155,7 +158,7 @@
</div> </div>
{#if expandedNodes[node.id]} {#if expandedNodes[node.id]}
{#each childrenOf(node.id) as child (child.id)} {#each childrenOf(node.id) as child (child.id)}
<svelte:self node={child} {nodes} {currentNodeId} {expandedNodes} depth={depth + 1} {icon} {toggle} {hasKids} {selectNode} {openCreate} /> <svelte:self node={child} {nodes} {currentNodeId} {expandedNodes} depth={depth + 1} {toggle} {hasKids} {selectNode} {openCreate} />
{/each} {/each}
{/if} {/if}
</div> </div>
@ -178,7 +181,8 @@
.wt-expand { width: 1rem; height: 1rem; min-height: 0; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #666; background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; } .wt-expand { width: 1rem; height: 1rem; min-height: 0; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #666; background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; }
.wt-expand:hover { color: #e0e0f0; } .wt-expand:hover { color: #e0e0f0; }
.wt-expand-spacer { width: 1rem; flex-shrink: 0; } .wt-expand-spacer { width: 1rem; flex-shrink: 0; }
.wt-icon { font-size: 0.8rem; flex-shrink: 0; } .wt-icon { width: 0.9rem; height: 0.9rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: #a0a0b8; }
:global(.wt-node-icon), :global(.wt-expand-icon) { display: block; }
.wt-label { flex: 1; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wt-label { flex: 1; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: #4ecca3; } .wt-label:hover { color: #4ecca3; }
.wt-node.archived .wt-label { text-decoration: line-through; opacity: 0.5; } .wt-node.archived .wt-label { text-decoration: line-through; opacity: 0.5; }

View File

@ -154,9 +154,9 @@
status: 'initialized', status: 'initialized',
currentNodeId: 'case-alpha', currentNodeId: 'case-alpha',
nodes: [ nodes: [
{ id: 'space-main', parentId: '', type: 'space', title: 'Main Space', status: 'active', order: 1 }, { id: 'space-main', parentId: '', type: 'space', title: 'Main Space', path: 'Main Space', status: 'active', order: 1 },
{ id: 'case-alpha', parentId: 'space-main', type: 'case', title: 'Alpha Case', status: 'active', order: 1 }, { id: 'case-alpha', parentId: 'space-main', type: 'case', title: 'Alpha Case', path: 'Main Space/Alpha Case', status: 'active', order: 1 },
{ id: 'case-beta', parentId: 'space-main', type: 'case', title: 'Beta Case', status: 'active', order: 2 } { id: 'case-beta', parentId: 'space-main', type: 'case', title: 'Beta Case', path: 'Main Space/Beta Case', status: 'active', order: 2 }
] ]
}; };
} }
@ -176,7 +176,12 @@
'Docs/todo.txt': { type: 'file', content: 'Buy groceries\nWrite tests', modifiedAt: new Date().toISOString() }, 'Docs/todo.txt': { type: 'file', content: 'Buy groceries\nWrite tests', modifiedAt: new Date().toISOString() },
'Docs/readme.md': { type: 'file', content: '# Hello World\n\nThis is a **test** document.\n\n- item 1\n- item 2', modifiedAt: new Date().toISOString() }, 'Docs/readme.md': { type: 'file', content: '# Hello World\n\nThis is a **test** document.\n\n- item 1\n- item 2', modifiedAt: new Date().toISOString() },
'Notes': { type: 'folder', modifiedAt: new Date().toISOString() }, 'Notes': { type: 'folder', modifiedAt: new Date().toISOString() },
'Notes/Overview.md': { type: 'file', content: '# Notes Overview\n\nMy notes content here.', modifiedAt: new Date().toISOString() } 'Notes/Overview.md': { type: 'file', content: '# Notes Overview\n\nMy notes content here.', modifiedAt: new Date().toISOString() },
'Main Space': { type: 'folder', modifiedAt: new Date().toISOString() },
'Main Space/Alpha Case': { type: 'folder', modifiedAt: new Date().toISOString() },
'Main Space/Alpha Case/alpha-only.txt': { type: 'file', content: 'alpha file', modifiedAt: new Date().toISOString() },
'Main Space/Beta Case': { type: 'folder', modifiedAt: new Date().toISOString() },
'Main Space/Beta Case/beta-only.txt': { type: 'file', content: 'beta file', modifiedAt: new Date().toISOString() }
}; };
} }
@ -379,6 +384,12 @@
'(function(){', '(function(){',
'var DefaultEditor={', 'var DefaultEditor={',
'mount:function(c,p,api){', 'mount:function(c,p,api){',
'if(!document.getElementById("mock-default-editor-styles")){',
'var style=document.createElement("style");',
'style.id="mock-default-editor-styles";',
'style.textContent=".de-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}.de-toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid #16213e;flex-shrink:0;background:#12122a}.de-toolbar-mode{font-size:.75rem;color:#4ecca3;padding:.15rem .5rem;border-radius:3px;background:#1a2a3a}.de-toolbar-context{font-size:.7rem;color:#8b8ba8}.de-editor-wrap{flex:1;display:flex;min-height:0;overflow:hidden}.de-textarea{flex:1;width:100%;height:100%;resize:none;border:0;outline:0;padding:.75rem;font-family:monospace;font-size:.85rem;line-height:1.6;background:#0d0d1a;color:#e0e0e0}.de-preview{flex:1;height:100%;padding:.75rem 1rem;overflow-y:auto;background:#0d0d1a;line-height:1.7;font-size:.9rem}.de-notes-badge{font-size:.65rem;padding:.1rem .4rem;border-radius:3px;background:#2a1a3a;color:#b388ff}";',
'document.head.appendChild(style);',
'}',
'c.innerHTML="";', 'c.innerHTML="";',
'c.className="de-root";', 'c.className="de-root";',
'var req=p.request||{};', 'var req=p.request||{};',
@ -442,13 +453,14 @@
"c.innerHTML='';", "c.innerHTML='';",
"c.className='files-root';", "c.className='files-root';",
"c.setAttribute('data-plugin-id','verstak.files');", "c.setAttribute('data-plugin-id','verstak.files');",
"var root=String((p&&(p.workspaceRootPath||(p.workspaceNode&&p.workspaceNode.path)))||'').split('/').filter(Boolean).join('/');",
"var list=document.createElement('div');", "var list=document.createElement('div');",
"list.className='files-list';", "list.className='files-list';",
"list.setAttribute('data-files-list','');", "list.setAttribute('data-files-list','');",
"c.appendChild(list);", "c.appendChild(list);",
"function load(){", "function load(){",
"list.textContent='Loading...';", "list.textContent='Loading...';",
"api.files.list('').then(function(entries){", "api.files.list(root).then(function(entries){",
"list.innerHTML='';", "list.innerHTML='';",
"if(!entries||!entries.length){list.textContent='Empty folder';return;}", "if(!entries||!entries.length){list.textContent='Empty folder';return;}",
"entries.forEach(function(e){", "entries.forEach(function(e){",

View File

@ -1,6 +1,6 @@
<script> <script>
/** /**
* Icon.svelte — Renders inline SVG icons. * Icon.svelte — Renders bundled Lucide SVG icons.
* *
* Usage: * Usage:
* <Icon name="puzzle" size={18} /> * <Icon name="puzzle" size={18} />
@ -8,26 +8,60 @@
* *
* Rules: * Rules:
* - NO emoji, NO unicode pictographic symbols * - NO emoji, NO unicode pictographic symbols
* - ALL icons must be SVG registered in ../ui/icons.js * - NO dependency on system icon fonts
* - If name is not found, renders a default dot icon * - If name is not found, renders a default circle icon
*/ */
import { iconPaths } from './icons.js'; import Briefcase from 'lucide-svelte/icons/briefcase';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Circle from 'lucide-svelte/icons/circle';
import FlaskConical from 'lucide-svelte/icons/flask-conical';
import Folder from 'lucide-svelte/icons/folder';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import PanelsTopLeft from 'lucide-svelte/icons/panels-top-left';
import Pencil from 'lucide-svelte/icons/pencil';
import Plug from 'lucide-svelte/icons/plug';
import Puzzle from 'lucide-svelte/icons/puzzle';
import Settings from 'lucide-svelte/icons/settings';
import Shield from 'lucide-svelte/icons/shield';
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
export let name = 'dot'; export let name = 'dot';
export let size = 16; export let size = 16;
export let className = ''; export let className = '';
$: icon = iconPaths[name] || iconPaths.dot; const icons = {
case: Briefcase,
chevronDown: ChevronDown,
chevronRight: ChevronRight,
dot: Circle,
edit: Pencil,
flask: FlaskConical,
folder: Folder,
gear: Settings,
logo: PanelsTopLeft,
plugin: Plug,
puzzle: Puzzle,
space: LayoutGrid,
vault: Shield,
warning: TriangleAlert,
};
const aliases = {
'🧪': 'flask',
danger: 'warning',
settings: 'gear',
};
$: iconKey = aliases[name] || name || 'dot';
$: IconComponent = icons[iconKey] || icons.dot;
$: iconClass = [className, $$restProps.class].filter(Boolean).join(' ');
</script> </script>
<svg <svelte:component
xmlns="http://www.w3.org/2000/svg" this={IconComponent}
viewBox={icon.viewBox} size={size}
width={size} {...$$restProps}
height={size} class={iconClass}
class={className} aria-hidden="true"
> />
{#each icon.paths as p}
<path d={p.d} {...p.attrs} />
{/each}
</svg>

View File

@ -1,128 +0,0 @@
/**
* icons.js Centralised SVG icon set for Verstak.
*
* RULE: Icons MUST be SVG only. No emoji, no unicode symbols, no font-icons.
* The Wails WebKitGTK webview does NOT render colour emoji.
*
* Each icon is an object: { viewBox, paths }
* - viewBox: string, default '0 0 24 24'
* - paths: array of <path> attributes or objects with { d, ...attrs }
*
* Usage in Svelte:
* import { iconPaths } from '../lib/ui/icons.js';
* <svg viewBox={iconPaths.puzzle.viewBox} width="16" height="16">
* {#each iconPaths.puzzle.paths as p}
* <path d={p.d} {...p.attrs} />
* {/each}
* </svg>
*/
export const iconPaths = {
/** Plugin Manager — puzzle piece */
puzzle: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M4 5a3 3 0 0 1 3-3h4v2a2 2 0 0 0 4 0V2h4a2 2 0 0 1 2 2v4h-2a2 2 0 0 0 0 4h2v4a2 2 0 0 1-2 2h-4v-2a2 2 0 0 0-4 0v2H7a3 3 0 0 1-3-3v-3h2a2 2 0 0 0 0-4H4V5Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Platform Test / Diagnostics — flask/test-tube */
flask: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M9 2v5.586l-5.293 5.293A1 1 0 0 0 4 14v4a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-4a1 1 0 0 0-.293-.707L15 7.586V2H9Zm2 0v6a1 1 0 0 0 .293.707l5 5V14a1 1 0 0 1-1 1h-1l-2-2-2 2H8a1 1 0 0 1-1-1v-.293l3-3V2h3Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Verstak logo — stack/tray */
logo: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M5 3h14a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm0 2v3h14V5H5Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
{ d: 'M5 12h14a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2Zm0 2v3h14v-3H5Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Default fallback — circle */
dot: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 6a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Vault — safe / shield */
vault: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4Zm0 2.18L19 6.4v4.6c0 4.5-3.07 8.68-7 9.82-3.93-1.14-7-5.32-7-9.82V6.4l7-3.22ZM11 8v2H9v2h2v4h2v-4h2v-2h-2V8h-2Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Settings — gear */
gear: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M19.14 13l.57-1.43 1.79-.5-1-2.29-1.64.73-.29-.28-.73-1.64 2.29-1-1-2.29-1.79.5-.57 1.43-2.17.17-.57-1.43-1.79-.5-1 2.29 1.64.73-.29.28-.73 1.64-2.29-1-1 2.29 1.79.5.57 1.43-.57 1.43-1.79.5 1 2.29 1.64-.73.29.28.73 1.64-2.29 1 1 2.29 1.79-.5.57-1.43 2.17-.17.57 1.43 1.79.5 1-2.29-1.64-.73.29-.28.73-1.64 2.29 1 1-2.29-1.79-.5-.57-1.43ZM12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Warning / error — triangle */
warning: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M1 21h22L12 2 1 21Zm12-3h-2v-2h2v2Zm0-4h-2v-4h2v4Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** General plugin — extension/puzzle alternative */
plugin: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M11 2H5v6.172a3 3 0 0 0 0 5.656V20a1 1 0 0 0 1 1h4v-2H7v-4a1 1 0 0 0-1-1H5v-2h1a1 1 0 0 0 1-1V8h2V4h2a1 1 0 0 0 1-1V2h-1Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
{ d: 'M17 2a3 3 0 0 1 3 3v1h-4V5a1 1 0 0 0-1-1h-2v2h2v2h-2v2h4V8h2v2a3 3 0 0 1-3 3h-1v2h1a2 2 0 0 1 2 2v2h2v2a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2h2v-2h-2v-2h4a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2v2h-2V4a2 2 0 0 1 2-2h3Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
};
/**
* Render an SVG icon string inline.
* Returns an SVG string suitable for {@html } or innerHTML.
* @param {string} name - icon key
* @param {number|string} size - width/height
* @param {string} className - optional CSS class
*/
export function svgIcon(name, size = 16, className = '') {
const icon = iconPaths[name];
if (!icon) return svgIcon('dot', size, className);
const paths = icon.paths
.map(p => {
const attrs = p.attrs
? Object.entries(p.attrs).map(([k, v]) => `${k}="${v}"`).join(' ')
: '';
return `<path d="${p.d}"${attrs ? ' ' + attrs : ''}/>`;
})
.join('');
const cls = className ? ` class="${className}"` : '';
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${icon.viewBox}" width="${size}" height="${size}"${cls}>${paths}</svg>`;
}

View File

@ -10,11 +10,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -43,6 +45,7 @@ type WorkspaceNode struct {
ParentID string `json:"parentId,omitempty"` ParentID string `json:"parentId,omitempty"`
Type NodeType `json:"type"` Type NodeType `json:"type"`
Title string `json:"title"` Title string `json:"title"`
Path string `json:"path,omitempty"`
Status NodeStatus `json:"status"` Status NodeStatus `json:"status"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Order int `json:"order"` Order int `json:"order"`
@ -88,6 +91,9 @@ func (m *Manager) Load() error {
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
m.tree = m.defaultTree() m.tree = m.defaultTree()
if _, err := m.ensureWorkspacePathsLocked(); err != nil {
return err
}
return m.saveLocked() return m.saveLocked()
} }
return fmt.Errorf("failed to read workspace.json: %w", err) return fmt.Errorf("failed to read workspace.json: %w", err)
@ -113,6 +119,13 @@ func (m *Manager) Load() error {
} }
m.tree = &tree m.tree = &tree
changed, err := m.ensureWorkspacePathsLocked()
if err != nil {
return err
}
if changed {
return m.saveLocked()
}
return nil return nil
} }
@ -159,6 +172,7 @@ func (m *Manager) defaultTree() *WorkspaceTree {
ID: uuid.New().String(), ID: uuid.New().String(),
Type: TypeSpace, Type: TypeSpace,
Title: "My Workspace", Title: "My Workspace",
Path: safePathSegment("My Workspace"),
Status: StatusActive, Status: StatusActive,
Order: 0, Order: 0,
CreatedAt: now, CreatedAt: now,
@ -261,22 +275,148 @@ func (m *Manager) CreateNode(parentID string, nodeType NodeType, title string) (
ParentID: parentID, ParentID: parentID,
Type: nodeType, Type: nodeType,
Title: title, Title: title,
Path: m.uniqueWorkspacePathLocked(parentID, title, ""),
Status: StatusActive, Status: StatusActive,
Order: maxOrder + 1, Order: maxOrder + 1,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if err := os.MkdirAll(filepath.Join(m.vaultDir, filepath.FromSlash(node.Path)), 0o755); err != nil {
return WorkspaceNode{}, fmt.Errorf("failed to create workspace folder: %w", err)
}
m.tree.Nodes = append(m.tree.Nodes, node) m.tree.Nodes = append(m.tree.Nodes, node)
if err := m.saveLocked(); err != nil { if err := m.saveLocked(); err != nil {
// Rollback: remove the node we just added // Rollback: remove the node we just added
m.tree.Nodes = m.tree.Nodes[:len(m.tree.Nodes)-1] m.tree.Nodes = m.tree.Nodes[:len(m.tree.Nodes)-1]
_ = os.Remove(filepath.Join(m.vaultDir, filepath.FromSlash(node.Path)))
return WorkspaceNode{}, fmt.Errorf("failed to save after create: %w", err) return WorkspaceNode{}, fmt.Errorf("failed to save after create: %w", err)
} }
return node, nil return node, nil
} }
func (m *Manager) ensureWorkspacePathsLocked() (bool, error) {
if m.tree == nil {
return false, fmt.Errorf("workspace tree is nil")
}
changed := false
resolved := make(map[string]string, len(m.tree.Nodes))
used := make(map[string]string, len(m.tree.Nodes))
for {
progress := false
for i := range m.tree.Nodes {
node := &m.tree.Nodes[i]
if _, ok := resolved[node.ID]; ok {
continue
}
parentPath := ""
if node.ParentID != "" {
var ok bool
parentPath, ok = resolved[node.ParentID]
if !ok {
continue
}
}
if node.Path == "" {
node.Path = m.uniqueWorkspacePathWithUsedLocked(parentPath, node.Title, node.ID, used)
node.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
changed = true
}
resolved[node.ID] = node.Path
used[node.Path] = node.ID
if err := os.MkdirAll(filepath.Join(m.vaultDir, filepath.FromSlash(node.Path)), 0o755); err != nil {
return false, fmt.Errorf("failed to create workspace folder %q: %w", node.Path, err)
}
progress = true
}
if len(resolved) == len(m.tree.Nodes) {
return changed, nil
}
if !progress {
return changed, fmt.Errorf("workspace tree has nodes with missing parents")
}
}
}
func (m *Manager) uniqueWorkspacePathLocked(parentID, title, excludeID string) string {
excluded := map[string]bool{}
if excludeID != "" {
excluded[excludeID] = true
}
return m.uniqueWorkspacePathExcludingLocked(parentID, title, excluded)
}
func (m *Manager) uniqueWorkspacePathExcludingLocked(parentID, title string, excluded map[string]bool) string {
parentPath := ""
if parentID != "" {
for _, n := range m.tree.Nodes {
if n.ID == parentID {
parentPath = n.Path
break
}
}
}
used := make(map[string]string, len(m.tree.Nodes))
for _, n := range m.tree.Nodes {
if !excluded[n.ID] && n.Path != "" {
used[n.Path] = n.ID
}
}
return m.uniqueWorkspacePathWithUsedLocked(parentPath, title, "", used)
}
func (m *Manager) uniqueWorkspacePathWithUsedLocked(parentPath, title, excludeID string, used map[string]string) string {
segment := safePathSegment(title)
for i := 1; i < 1000; i++ {
candidateSegment := segment
if i > 1 {
candidateSegment = fmt.Sprintf("%s (%d)", segment, i)
}
candidate := path.Join(parentPath, candidateSegment)
if owner, ok := used[candidate]; ok && owner != excludeID {
continue
}
if _, err := os.Stat(filepath.Join(m.vaultDir, filepath.FromSlash(candidate))); err == nil {
continue
}
return candidate
}
return path.Join(parentPath, fmt.Sprintf("%s_%d", segment, time.Now().UnixNano()))
}
func safePathSegment(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "Untitled"
}
var b strings.Builder
for _, r := range title {
switch {
case r == '/' || r == '\\':
b.WriteRune('_')
case r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|':
b.WriteRune(' ')
case unicode.IsControl(r):
case r == '.' && b.Len() == 0:
b.WriteRune('_')
default:
b.WriteRune(r)
}
}
segment := strings.TrimSpace(b.String())
if segment == "" {
return "Untitled"
}
if len(segment) > 200 {
segment = segment[:200]
}
return segment
}
// RenameNode updates a node's title. // RenameNode updates a node's title.
func (m *Manager) RenameNode(id, title string) error { func (m *Manager) RenameNode(id, title string) error {
if strings.TrimSpace(title) == "" { if strings.TrimSpace(title) == "" {
@ -353,11 +493,48 @@ func (m *Manager) MoveNode(id, newParentID string) error {
} }
} }
oldNodes := append([]WorkspaceNode(nil), m.tree.Nodes...)
oldParentID := m.tree.Nodes[nodeIdx].ParentID
oldPath := m.tree.Nodes[nodeIdx].Path
subtree := m.subtreeIDsLocked(id)
newPath := oldPath
if newParentID != oldParentID {
newPath = m.uniqueWorkspacePathExcludingLocked(newParentID, m.tree.Nodes[nodeIdx].Title, subtree)
}
if oldPath != newPath {
oldFull := filepath.Join(m.vaultDir, filepath.FromSlash(oldPath))
newFull := filepath.Join(m.vaultDir, filepath.FromSlash(newPath))
if err := os.MkdirAll(filepath.Dir(newFull), 0o755); err != nil {
return fmt.Errorf("failed to create destination parent folder: %w", err)
}
if _, err := os.Stat(oldFull); err == nil {
if err := os.Rename(oldFull, newFull); err != nil {
return fmt.Errorf("failed to move workspace folder: %w", err)
}
} else if os.IsNotExist(err) {
if err := os.MkdirAll(newFull, 0o755); err != nil {
return fmt.Errorf("failed to create moved workspace folder: %w", err)
}
} else {
return err
}
}
m.tree.Nodes[nodeIdx].ParentID = newParentID m.tree.Nodes[nodeIdx].ParentID = newParentID
m.tree.Nodes[nodeIdx].Order = maxOrder + 1 m.tree.Nodes[nodeIdx].Order = maxOrder + 1
m.tree.Nodes[nodeIdx].Path = newPath
m.tree.Nodes[nodeIdx].UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) m.tree.Nodes[nodeIdx].UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
m.rewriteDescendantPathsLocked(id, oldPath, newPath)
return m.saveLocked() if err := m.saveLocked(); err != nil {
m.tree.Nodes = oldNodes
if oldPath != newPath {
_ = os.Rename(filepath.Join(m.vaultDir, filepath.FromSlash(newPath)), filepath.Join(m.vaultDir, filepath.FromSlash(oldPath)))
}
return err
}
return nil
} }
// isDescendant checks if targetID is a descendant of ancestorID. // isDescendant checks if targetID is a descendant of ancestorID.
@ -381,6 +558,38 @@ func (m *Manager) isDescendant(ancestorID, targetID string) bool {
return false return false
} }
func (m *Manager) subtreeIDsLocked(rootID string) map[string]bool {
subtree := map[string]bool{rootID: true}
changed := true
for changed {
changed = false
for _, n := range m.tree.Nodes {
if !subtree[n.ID] && subtree[n.ParentID] {
subtree[n.ID] = true
changed = true
}
}
}
return subtree
}
func (m *Manager) rewriteDescendantPathsLocked(rootID, oldRootPath, newRootPath string) {
if oldRootPath == "" || oldRootPath == newRootPath {
return
}
prefix := oldRootPath + "/"
now := time.Now().UTC().Format(time.RFC3339Nano)
for i := range m.tree.Nodes {
if m.tree.Nodes[i].ID == rootID {
continue
}
if strings.HasPrefix(m.tree.Nodes[i].Path, prefix) {
m.tree.Nodes[i].Path = newRootPath + strings.TrimPrefix(m.tree.Nodes[i].Path, oldRootPath)
m.tree.Nodes[i].UpdatedAt = now
}
}
}
// ArchiveNode sets a node's status to archived. // ArchiveNode sets a node's status to archived.
func (m *Manager) ArchiveNode(id string) error { func (m *Manager) ArchiveNode(id string) error {
m.mu.Lock() m.mu.Lock()

View File

@ -61,6 +61,12 @@ func TestCreateNode_Case(t *testing.T) {
if node.Status != StatusActive { if node.Status != StatusActive {
t.Errorf("status: got %s, want %s", node.Status, StatusActive) t.Errorf("status: got %s, want %s", node.Status, StatusActive)
} }
if node.Path != filepath.Join("My Workspace", "Test Case") {
t.Errorf("path: got %q, want %q", node.Path, filepath.Join("My Workspace", "Test Case"))
}
if _, err := os.Stat(filepath.Join(vaultDir, node.Path)); err != nil {
t.Fatalf("expected workspace folder to exist: %v", err)
}
// Verify persisted // Verify persisted
tree := m.GetTree() tree := m.GetTree()
@ -69,6 +75,34 @@ func TestCreateNode_Case(t *testing.T) {
} }
} }
func TestCreateNode_DuplicateTitlesGetUniquePaths(t *testing.T) {
dir := t.TempDir()
vaultDir := filepath.Join(dir, "vault")
os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
rootID := m.GetTree().Nodes[0].ID
first, err := m.CreateNode(rootID, TypeCase, "SameName")
if err != nil {
t.Fatalf("CreateNode first: %v", err)
}
second, err := m.CreateNode(rootID, TypeCase, "SameName")
if err != nil {
t.Fatalf("CreateNode second: %v", err)
}
if first.Path == second.Path {
t.Fatalf("expected unique paths, got %q", first.Path)
}
if second.Path != filepath.Join("My Workspace", "SameName (2)") {
t.Errorf("second path: got %q, want %q", second.Path, filepath.Join("My Workspace", "SameName (2)"))
}
}
func TestCreateNode_InvalidType(t *testing.T) { func TestCreateNode_InvalidType(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
vaultDir := filepath.Join(dir, "vault") vaultDir := filepath.Join(dir, "vault")
@ -146,6 +180,12 @@ func TestMoveNode(t *testing.T) {
if moved.ParentID != folder.ID { if moved.ParentID != folder.ID {
t.Errorf("parentID: got %q, want %q", moved.ParentID, folder.ID) t.Errorf("parentID: got %q, want %q", moved.ParentID, folder.ID)
} }
if moved.Path != filepath.Join("My Workspace", "Folder", "Case") {
t.Errorf("path: got %q, want %q", moved.Path, filepath.Join("My Workspace", "Folder", "Case"))
}
if _, err := os.Stat(filepath.Join(vaultDir, moved.Path)); err != nil {
t.Fatalf("expected moved folder to exist: %v", err)
}
} }
func TestMoveNode_CannotMoveIntoSelf(t *testing.T) { func TestMoveNode_CannotMoveIntoSelf(t *testing.T) {
@ -165,6 +205,27 @@ func TestMoveNode_CannotMoveIntoSelf(t *testing.T) {
} }
} }
func TestMoveNode_SameParentKeepsPath(t *testing.T) {
dir := t.TempDir()
vaultDir := filepath.Join(dir, "vault")
os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
m := NewManager(vaultDir)
m.Load()
rootID := m.GetTree().Nodes[0].ID
node, _ := m.CreateNode(rootID, TypeCase, "Case")
if err := m.MoveNode(node.ID, rootID); err != nil {
t.Fatalf("MoveNode: %v", err)
}
moved, _ := m.GetNode(node.ID)
if moved.Path != node.Path {
t.Errorf("path changed on same-parent move: got %q, want %q", moved.Path, node.Path)
}
}
func TestMoveNode_CannotMoveIntoDescendant(t *testing.T) { func TestMoveNode_CannotMoveIntoDescendant(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
vaultDir := filepath.Join(dir, "vault") vaultDir := filepath.Join(dir, "vault")

View File

@ -15,34 +15,34 @@ if [ ! -d "$OFFICIAL_PLUGINS" ]; then
exit 1 exit 1
fi fi
# ── ensure dist package exists ── # ── ensure dist packages exist ──
DIST_PACKAGE="$OFFICIAL_PLUGINS/dist/platform-test" DIST_ROOT="$OFFICIAL_PLUGINS/dist"
if [ ! -d "$DIST_PACKAGE" ]; then if [ ! -d "$DIST_ROOT" ] || [ -z "$(find "$DIST_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)" ]; then
echo " dist package not found at $DIST_PACKAGE" echo " dist packages not found at $DIST_ROOT"
echo " → Running build.sh in verstak-official-plugins..." echo " → Running build.sh in verstak-official-plugins..."
(cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh) (cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh)
echo "" echo ""
if [ ! -d "$DIST_PACKAGE" ]; then if [ ! -d "$DIST_ROOT" ] || [ -z "$(find "$DIST_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)" ]; then
echo "❌ dist package still missing after build" echo "❌ dist packages still missing after build"
exit 1 exit 1
fi fi
fi fi
# ── create ./plugins/platform-test ── # ── create ./plugins/* from official dist packages ──
PLUGIN_DIR="$ROOT/plugins/platform-test" PLUGIN_DIR="$ROOT/plugins"
echo " → installing platform-test to $PLUGIN_DIR" echo " → installing official plugins to $PLUGIN_DIR"
mkdir -p "$ROOT/plugins" mkdir -p "$ROOT/plugins"
# Clean up any leftover temp directories # Clean up any leftover temp directories
for tmp in "$ROOT/plugins"/.platform-test-tmp.*; do for tmp in "$ROOT"/.official-plugins-tmp.*; do
[ -d "$tmp" ] && rm -rf "$tmp" [ -d "$tmp" ] && rm -rf "$tmp"
done done
# Atomic replace: install to temp then rename # Atomic replace: install to temp then rename
TMP_DIR=$(mktemp -d "$ROOT/plugins/.platform-test-tmp.XXXXXX") TMP_DIR=$(mktemp -d "$ROOT/.official-plugins-tmp.XXXXXX")
cp -r "$DIST_PACKAGE/." "$TMP_DIR/" cp -r "$DIST_ROOT/"* "$TMP_DIR/"
# Remove old directory (fix permissions first if needed) # Remove old plugin directories (fix permissions first if needed)
if [ -d "$PLUGIN_DIR" ]; then if [ -d "$PLUGIN_DIR" ]; then
chmod -R u+rwx "$PLUGIN_DIR" 2>/dev/null || true chmod -R u+rwx "$PLUGIN_DIR" 2>/dev/null || true
rm -rf "$PLUGIN_DIR" rm -rf "$PLUGIN_DIR"
@ -50,14 +50,12 @@ fi
mv "$TMP_DIR" "$PLUGIN_DIR" mv "$TMP_DIR" "$PLUGIN_DIR"
# ── verify ── # ── verify ──
if [ -f "$PLUGIN_DIR/plugin.json" ]; then if [ -d "$PLUGIN_DIR" ]; then
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json')).get('id','unknown'))" 2>/dev/null || echo "unknown")
FILE_COUNT=$(find "$PLUGIN_DIR" -type f | wc -l) FILE_COUNT=$(find "$PLUGIN_DIR" -type f | wc -l)
echo " ✅ installed: $PLUGIN_DIR" echo " ✅ installed: $PLUGIN_DIR"
echo " plugin id: $PLUGIN_ID"
echo " files: $FILE_COUNT" echo " files: $FILE_COUNT"
else else
echo "❌ install failed: plugin.json missing in $PLUGIN_DIR" echo "❌ install failed: $PLUGIN_DIR missing"
exit 1 exit 1
fi fi

View File

@ -29,49 +29,21 @@ OFFICIAL="$VERSTAK_ROOT/verstak-official-plugins"
if [ ! -d "$OFFICIAL" ]; then if [ ! -d "$OFFICIAL" ]; then
echo " ⚠️ verstak-official-plugins not found — skipping" echo " ⚠️ verstak-official-plugins not found — skipping"
else else
# npm deps (cd "$OFFICIAL" && ./scripts/build.sh)
if [ ! -d "$OFFICIAL/node_modules" ] && [ -f "$OFFICIAL/package.json" ]; then
echo " 📦 installing npm deps..."
(cd "$OFFICIAL" && npm install --no-audit --no-fund)
fi
# Build each plugin that has a frontend or backend
for plugin_dir in "$OFFICIAL"/plugins/*/; do
[ -d "$plugin_dir" ] || continue
plugin_name="$(basename "$plugin_dir")"
# Frontend build
fe_dir="$plugin_dir/frontend"
if [ -d "$fe_dir" ] && [ -f "$fe_dir/package.json" ]; then
echo "[$plugin_name] building frontend..."
if [ ! -d "$fe_dir/node_modules" ]; then
(cd "$fe_dir" && npm install --no-audit --no-fund)
fi
(cd "$fe_dir" && npm run build)
echo "$plugin_name frontend"
fi
# Backend build
backend_dir="$plugin_dir/backend"
if [ -f "$backend_dir/main.go" ]; then
echo "[$plugin_name] building backend..."
(cd "$backend_dir" && go build -o "$(basename "$backend_dir")" .)
echo "$plugin_name backend"
fi
done
echo " ✅ official plugins built" echo " ✅ official plugins built"
fi fi
# ── 3. Copy plugins to desktop ── # ── 3. Copy plugin packages to desktop ──
echo "" echo ""
echo "=== install plugins to desktop ===" echo "=== install plugins to desktop ==="
DEST="$ROOT/plugins" DEST="$ROOT/plugins"
rm -rf "$DEST" rm -rf "$DEST"
mkdir -p "$DEST" mkdir -p "$DEST"
if [ -d "$OFFICIAL/plugins" ]; then if [ -d "$OFFICIAL/dist" ] && [ -n "$(find "$OFFICIAL/dist" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)" ]; then
cp -r "$OFFICIAL/plugins/"* "$DEST/" 2>/dev/null cp -r "$OFFICIAL/dist/"* "$DEST/" 2>/dev/null
echo " ✅ plugins copied to $DEST" echo " ✅ plugins copied to $DEST"
else else
echo " no plugins to copy" echo " no plugin packages to copy"
fi fi
# ── 4. Build desktop ── # ── 4. Build desktop ──