fix: polish workspace files and editor shell
This commit is contained in:
parent
a6412fa070
commit
5c979174f1
|
|
@ -54,9 +54,11 @@ Canonical rules:
|
|||
|
||||
Canonical scoped paths:
|
||||
|
||||
- Workspace/root overview notes live under `Notes/`.
|
||||
- Case/project/folder scoped notes live under `<case-or-parent>/Notes/`.
|
||||
- The default overview note is `<case-or-parent>/Notes/Overview.md`.
|
||||
- Workspace/root overview notes live under `<workspace-node-path>/Notes/`.
|
||||
- Case/project/folder scoped notes live under `<workspace-node-path>/Notes/`.
|
||||
- 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ foreach plugin:
|
|||
|---|---|---|
|
||||
| `description` | string | Описание плагина |
|
||||
| `source` | string | `"official"`, `"local"`, `"third-party"` |
|
||||
| `icon` | string | Иконка (emoji или имя) |
|
||||
| `icon` | string | Имя иконки из встроенного Lucide-набора shell |
|
||||
| `requires` | string[] | Жёзкие capability-зависимости |
|
||||
| `optionalRequires` | string[] | Мягкие capability-зависимости |
|
||||
| `frontend` | object | `{ "entry": "path/to/index.js", "style": "path/to/style.css" }` |
|
||||
|
|
@ -180,6 +180,8 @@ foreach plugin:
|
|||
|
||||
Плагины могут регистрировать 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)
|
||||
|
||||
| Тип | Поле manifest | Описание | Frontend host |
|
||||
|
|
@ -210,7 +212,7 @@ foreach plugin:
|
|||
{
|
||||
"id": "mypanel.sidebar",
|
||||
"title": "My Panel",
|
||||
"icon": "📌",
|
||||
"icon": "puzzle",
|
||||
"view": "mypanel.view",
|
||||
"position": 100
|
||||
}
|
||||
|
|
@ -219,7 +221,7 @@ foreach plugin:
|
|||
{
|
||||
"id": "mypanel.view",
|
||||
"title": "My Panel View",
|
||||
"icon": "📌",
|
||||
"icon": "puzzle",
|
||||
"component": "MyPanelComponent"
|
||||
}
|
||||
],
|
||||
|
|
@ -234,7 +236,7 @@ foreach plugin:
|
|||
{
|
||||
"id": "mypanel.cmd",
|
||||
"title": "Do Something",
|
||||
"icon": "⚡",
|
||||
"icon": "gear",
|
||||
"handler": "doSomething"
|
||||
}
|
||||
]
|
||||
|
|
@ -717,12 +719,13 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
|
|||
│ │ │ │ │
|
||||
│ │ Verstak │ PluginManager | ViewContainer │ │
|
||||
│ │ │ │ │
|
||||
│ │ 🧩 Plugin│ (padding: 1.5rem) │ │
|
||||
│ │ [icon] │ (padding: 1.5rem) │ │
|
||||
│ │ Plugin │ │ │
|
||||
│ │ Manager│ │ │
|
||||
│ │ │ │ │
|
||||
│ │ Plugins │ │ │
|
||||
│ │ 📌 item1 │ │ │
|
||||
│ │ 📌 item2 │ │ │
|
||||
│ │ [icon] item1 │ │
|
||||
│ │ [icon] item2 │ │
|
||||
│ │ │ │ │
|
||||
│ │ ● Vault │ │ │
|
||||
│ └──────────┴──────────────────────────────────────┘ │
|
||||
|
|
@ -759,6 +762,7 @@ Workspace — центральная модель Верстака вокруг
|
|||
| `parentId` | string | ID родителя (пусто для root) |
|
||||
| `type` | space/case/folder | Тип ноды |
|
||||
| `title` | string | Название |
|
||||
| `path` | string | Vault-relative папка ноды |
|
||||
| `status` | active/sleeping/archived | Жизненный цикл |
|
||||
| `tags` | string[] | Теги |
|
||||
| `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
|
||||
|
||||
|
|
@ -786,8 +803,15 @@ Workspace — центральная модель Верстака вокруг
|
|||
### Правила
|
||||
|
||||
- Root node создаётся при создании vault
|
||||
- Для каждой node создаётся обычная папка внутри vault
|
||||
- WorkspaceItems получают выбранную node и `workspaceRootPath`; Files plugin
|
||||
показывает именно эту папку, а не общий root vault
|
||||
- Порядок children стабилен (sort by order)
|
||||
- Нельзя переместить ноду в себя или в своего потомка
|
||||
- `MoveWorkspaceNode` переносит physical folder subtree and updates descendant
|
||||
paths
|
||||
- `RenameWorkspaceNode` меняет display title; physical folder rename/UI для этого
|
||||
остаётся отдельным действием
|
||||
- Архивирование — soft delete (status = archived)
|
||||
- Corrupt JSON → backup + defaults
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,19 @@ test.describe('G: Files Plugin', () => {
|
|||
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 }) => {
|
||||
await page.evaluate(async () => {
|
||||
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"]');
|
||||
await expect(editor).toBeVisible({ timeout: 10000 });
|
||||
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 }) => {
|
||||
|
|
@ -59,6 +77,11 @@ test.describe('G: Files Plugin', () => {
|
|||
const workbench = page.locator('.workbench-host');
|
||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||
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 }) => {
|
||||
|
|
|
|||
|
|
@ -81,4 +81,15 @@ test.describe('E: Plugin Manager layout', () => {
|
|||
await expect(selected).toHaveCount(1);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"svelte": "^4.2.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
|
|
@ -982,6 +983,16 @@
|
|||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"svelte": "^4.2.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
43be2fbdf6ba6ca9504a7c4b0ac32ae0
|
||||
c02aa6458981565332c3b0bc49516b42
|
||||
|
|
@ -17,13 +17,15 @@
|
|||
let currentPluginId = null;
|
||||
let currentComponent = null;
|
||||
let currentAPI = null;
|
||||
let currentPropsKey = '';
|
||||
|
||||
$: activePluginId = pluginId || viewPluginId;
|
||||
$: activeComponent = componentId;
|
||||
$: propsKey = JSON.stringify(componentProps || {});
|
||||
|
||||
// React to changes — reload on view change
|
||||
$: if (activePluginId && activeComponent) {
|
||||
loadAndMount(activePluginId, activeComponent);
|
||||
loadAndMount(activePluginId, activeComponent, propsKey);
|
||||
} else if (!activePluginId) {
|
||||
cleanup();
|
||||
loadState = 'idle';
|
||||
|
|
@ -58,6 +60,7 @@
|
|||
currentPluginId = null;
|
||||
currentComponent = null;
|
||||
currentAPI = null;
|
||||
currentPropsKey = '';
|
||||
}
|
||||
|
||||
function unpackBackendResult(result) {
|
||||
|
|
@ -67,9 +70,9 @@
|
|||
return { value: result, error: '' };
|
||||
}
|
||||
|
||||
async function loadAndMount(pId, compId) {
|
||||
async function loadAndMount(pId, compId, nextPropsKey) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +83,7 @@
|
|||
errorText = '';
|
||||
currentPluginId = pId;
|
||||
currentComponent = compId;
|
||||
currentPropsKey = nextPropsKey;
|
||||
|
||||
try {
|
||||
// Get plugin frontend info
|
||||
|
|
@ -181,7 +185,7 @@
|
|||
|
||||
{:else if loadState === '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>
|
||||
<div class="error-details">
|
||||
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
|
||||
|
|
@ -209,17 +213,20 @@
|
|||
bind:this={mountContainer}
|
||||
data-plugin-id={currentPluginId}
|
||||
data-component={currentComponent}
|
||||
style="flex:1;min-width:0;min-height:0;height:100%;display:flex;flex-direction:column;position:relative;"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.plugin-bundle-host {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.host-state {
|
||||
|
|
@ -305,7 +312,12 @@
|
|||
}
|
||||
|
||||
.plugin-mount-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@
|
|||
{@const isDangerous = dangerousPermissions.includes(perm)}
|
||||
<span class="tag" class:dangerous={isDangerous}>
|
||||
{perm}
|
||||
{#if isDangerous}<Icon name="warning" size={12} className="danger-icon" />{/if}
|
||||
{#if isDangerous}<Icon name="warning" size={12} class="danger-icon" />{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -362,7 +362,7 @@
|
|||
}
|
||||
|
||||
.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 {
|
||||
color: #ffc857;
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@
|
|||
<div class="loading">Scanning plugin directories...</div>
|
||||
{:else if 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>
|
||||
<button class="retry-btn" on:click={loadAll} type="button">⟳ Retry</button>
|
||||
</div>
|
||||
|
|
@ -454,7 +454,7 @@
|
|||
padding: 2rem; text-align: center; color: #a0a0b8;
|
||||
}
|
||||
.error { color: #e94560; }
|
||||
.error-icon { color: #e94560; margin-bottom: 0.5rem; }
|
||||
:global(.error-icon) { color: #e94560; margin-bottom: 0.5rem; }
|
||||
.error-message {
|
||||
font-family: monospace; font-size: 0.85rem; margin-bottom: 1rem; word-break: break-word;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
<aside class="sidebar">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
on:click={() => handleNav(item.id)}
|
||||
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>
|
||||
</button>
|
||||
{/each}
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
on:click={() => handleSidebarItem(item)}
|
||||
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>
|
||||
</button>
|
||||
{/each}
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
<div class="sidebar-footer">
|
||||
{#if errorMessage}
|
||||
<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
|
||||
</span>
|
||||
{/if}
|
||||
|
|
@ -152,7 +152,7 @@
|
|||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
:global(.sidebar-logo) {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
color: #4ecca3;
|
||||
|
|
@ -218,13 +218,13 @@
|
|||
color: #e0e0f0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
:global(.nav-icon) {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
color: currentColor;
|
||||
}
|
||||
.nav-icon.icon-plugin {
|
||||
:global(.nav-icon.icon-plugin) {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
|
|
@ -261,7 +261,7 @@
|
|||
color: #e94560;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.sidebar-error-icon {
|
||||
:global(.sidebar-error-icon) {
|
||||
color: #e94560;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@
|
|||
|
||||
{#if error}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
{#each recentVaults as path}
|
||||
<li>
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
|
|
@ -247,7 +247,7 @@
|
|||
font-size: 0.85rem;
|
||||
color: #e94560;
|
||||
}
|
||||
.error-icon { flex-shrink: 0; }
|
||||
:global(.error-icon) { flex-shrink: 0; }
|
||||
.error-text { word-break: break-word; }
|
||||
.actions {
|
||||
display: flex;
|
||||
|
|
@ -369,7 +369,7 @@
|
|||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.recent-icon { flex-shrink: 0; }
|
||||
:global(.recent-icon) { flex-shrink: 0; }
|
||||
.recent-path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
<div class="view-container">
|
||||
<div class="error-boundary">
|
||||
<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-text">{renderError}</p>
|
||||
</div>
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
<div class="view-container">
|
||||
<div class="view" class:degraded={pluginStatus === 'degraded'}>
|
||||
<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>
|
||||
{#if hasFrontend}
|
||||
<span class="frontend-badge">frontend bundle</span>
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
color: #e0e0f0;
|
||||
flex: 1;
|
||||
}
|
||||
.view-icon {
|
||||
:global(.view-icon) {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
color: #a78bfa;
|
||||
|
|
@ -212,7 +212,7 @@
|
|||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.error-icon {
|
||||
:global(.error-icon) {
|
||||
color: #e94560;
|
||||
}
|
||||
.error-title {
|
||||
|
|
|
|||
|
|
@ -54,8 +54,10 @@
|
|||
|
||||
<style>
|
||||
.workbench-host {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1a1a2e;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
<PluginBundleHost
|
||||
pluginId={tool.pluginId}
|
||||
componentId={tool.component}
|
||||
componentProps={{ workspaceNodeId: currentNodeId, workspaceNode: currentNode }}
|
||||
componentProps={{ workspaceNodeId: currentNodeId, workspaceNode: currentNode, workspaceRootPath: currentNode.path || '' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
|
||||
export let nodes = [];
|
||||
export let node = null;
|
||||
|
|
@ -66,11 +67,11 @@
|
|||
return nodes.some(n => n.parentId === id);
|
||||
}
|
||||
|
||||
function icon(type) {
|
||||
if (type === 'space') return '\u{1F310}';
|
||||
if (type === 'case') return '\u{1F4CB}';
|
||||
if (type === 'folder') return '\u{1F4C1}';
|
||||
return '\u{1F4C4}';
|
||||
function iconName(type) {
|
||||
if (type === 'space') return 'space';
|
||||
if (type === 'case') return 'case';
|
||||
if (type === 'folder') return 'folder';
|
||||
return 'dot';
|
||||
}
|
||||
|
||||
async function selectNode(id) {
|
||||
|
|
@ -121,7 +122,7 @@
|
|||
<div class="wt-error">{localError}</div>
|
||||
{:else}
|
||||
{#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}
|
||||
{/if}
|
||||
|
||||
|
|
@ -129,7 +130,7 @@
|
|||
<div class="wt-create">
|
||||
<div class="wt-create-header">
|
||||
<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>
|
||||
<input type="text" bind:value={newNodeTitle} placeholder="Name..." disabled={creating} />
|
||||
<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-row" style="padding-left: {depth * 1.0 + 0.4}rem;">
|
||||
{#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}
|
||||
<span class="wt-expand-spacer"></span>
|
||||
{/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>
|
||||
{#if node.type !== 'case'}
|
||||
<button class="wt-btn wt-btn-small" on:click={() => openCreate(node.id, 'case')} title="Add child" type="button">+</button>
|
||||
|
|
@ -155,7 +158,7 @@
|
|||
</div>
|
||||
{#if expandedNodes[node.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}
|
||||
{/if}
|
||||
</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:hover { color: #e0e0f0; }
|
||||
.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:hover { color: #4ecca3; }
|
||||
.wt-node.archived .wt-label { text-decoration: line-through; opacity: 0.5; }
|
||||
|
|
|
|||
|
|
@ -154,9 +154,9 @@
|
|||
status: 'initialized',
|
||||
currentNodeId: 'case-alpha',
|
||||
nodes: [
|
||||
{ id: 'space-main', parentId: '', type: 'space', title: 'Main Space', status: 'active', order: 1 },
|
||||
{ id: 'case-alpha', parentId: 'space-main', type: 'case', title: 'Alpha Case', status: 'active', order: 1 },
|
||||
{ id: 'case-beta', parentId: 'space-main', type: 'case', title: 'Beta Case', status: 'active', order: 2 }
|
||||
{ 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', path: 'Main Space/Alpha Case', status: 'active', order: 1 },
|
||||
{ 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/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/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(){',
|
||||
'var DefaultEditor={',
|
||||
'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.className="de-root";',
|
||||
'var req=p.request||{};',
|
||||
|
|
@ -442,13 +453,14 @@
|
|||
"c.innerHTML='';",
|
||||
"c.className='files-root';",
|
||||
"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');",
|
||||
"list.className='files-list';",
|
||||
"list.setAttribute('data-files-list','');",
|
||||
"c.appendChild(list);",
|
||||
"function load(){",
|
||||
"list.textContent='Loading...';",
|
||||
"api.files.list('').then(function(entries){",
|
||||
"api.files.list(root).then(function(entries){",
|
||||
"list.innerHTML='';",
|
||||
"if(!entries||!entries.length){list.textContent='Empty folder';return;}",
|
||||
"entries.forEach(function(e){",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
/**
|
||||
* Icon.svelte — Renders inline SVG icons.
|
||||
* Icon.svelte — Renders bundled Lucide SVG icons.
|
||||
*
|
||||
* Usage:
|
||||
* <Icon name="puzzle" size={18} />
|
||||
|
|
@ -8,26 +8,60 @@
|
|||
*
|
||||
* Rules:
|
||||
* - NO emoji, NO unicode pictographic symbols
|
||||
* - ALL icons must be SVG registered in ../ui/icons.js
|
||||
* - If name is not found, renders a default dot icon
|
||||
* - NO dependency on system icon fonts
|
||||
* - 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 size = 16;
|
||||
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>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={icon.viewBox}
|
||||
width={size}
|
||||
height={size}
|
||||
class={className}
|
||||
>
|
||||
{#each icon.paths as p}
|
||||
<path d={p.d} {...p.attrs} />
|
||||
{/each}
|
||||
</svg>
|
||||
<svelte:component
|
||||
this={IconComponent}
|
||||
size={size}
|
||||
{...$$restProps}
|
||||
class={iconClass}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
@ -10,11 +10,13 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
|
@ -43,6 +45,7 @@ type WorkspaceNode struct {
|
|||
ParentID string `json:"parentId,omitempty"`
|
||||
Type NodeType `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Status NodeStatus `json:"status"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Order int `json:"order"`
|
||||
|
|
@ -88,6 +91,9 @@ func (m *Manager) Load() error {
|
|||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
m.tree = m.defaultTree()
|
||||
if _, err := m.ensureWorkspacePathsLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.saveLocked()
|
||||
}
|
||||
return fmt.Errorf("failed to read workspace.json: %w", err)
|
||||
|
|
@ -113,6 +119,13 @@ func (m *Manager) Load() error {
|
|||
}
|
||||
|
||||
m.tree = &tree
|
||||
changed, err := m.ensureWorkspacePathsLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
return m.saveLocked()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +172,7 @@ func (m *Manager) defaultTree() *WorkspaceTree {
|
|||
ID: uuid.New().String(),
|
||||
Type: TypeSpace,
|
||||
Title: "My Workspace",
|
||||
Path: safePathSegment("My Workspace"),
|
||||
Status: StatusActive,
|
||||
Order: 0,
|
||||
CreatedAt: now,
|
||||
|
|
@ -261,22 +275,148 @@ func (m *Manager) CreateNode(parentID string, nodeType NodeType, title string) (
|
|||
ParentID: parentID,
|
||||
Type: nodeType,
|
||||
Title: title,
|
||||
Path: m.uniqueWorkspacePathLocked(parentID, title, ""),
|
||||
Status: StatusActive,
|
||||
Order: maxOrder + 1,
|
||||
CreatedAt: 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)
|
||||
if err := m.saveLocked(); err != nil {
|
||||
// Rollback: remove the node we just added
|
||||
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 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.
|
||||
func (m *Manager) RenameNode(id, title string) error {
|
||||
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].Order = maxOrder + 1
|
||||
m.tree.Nodes[nodeIdx].Path = newPath
|
||||
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.
|
||||
|
|
@ -381,6 +558,38 @@ func (m *Manager) isDescendant(ancestorID, targetID string) bool {
|
|||
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.
|
||||
func (m *Manager) ArchiveNode(id string) error {
|
||||
m.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ func TestCreateNode_Case(t *testing.T) {
|
|||
if 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
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
vaultDir := filepath.Join(dir, "vault")
|
||||
|
|
@ -146,6 +180,12 @@ func TestMoveNode(t *testing.T) {
|
|||
if 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) {
|
||||
|
|
@ -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) {
|
||||
dir := t.TempDir()
|
||||
vaultDir := filepath.Join(dir, "vault")
|
||||
|
|
|
|||
|
|
@ -15,34 +15,34 @@ if [ ! -d "$OFFICIAL_PLUGINS" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# ── ensure dist package exists ──
|
||||
DIST_PACKAGE="$OFFICIAL_PLUGINS/dist/platform-test"
|
||||
if [ ! -d "$DIST_PACKAGE" ]; then
|
||||
echo " ℹ️ dist package not found at $DIST_PACKAGE"
|
||||
# ── ensure dist packages exist ──
|
||||
DIST_ROOT="$OFFICIAL_PLUGINS/dist"
|
||||
if [ ! -d "$DIST_ROOT" ] || [ -z "$(find "$DIST_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)" ]; then
|
||||
echo " ℹ️ dist packages not found at $DIST_ROOT"
|
||||
echo " → Running build.sh in verstak-official-plugins..."
|
||||
(cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh)
|
||||
echo ""
|
||||
if [ ! -d "$DIST_PACKAGE" ]; then
|
||||
echo "❌ dist package still missing after build"
|
||||
if [ ! -d "$DIST_ROOT" ] || [ -z "$(find "$DIST_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)" ]; then
|
||||
echo "❌ dist packages still missing after build"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── create ./plugins/platform-test ──
|
||||
PLUGIN_DIR="$ROOT/plugins/platform-test"
|
||||
echo " → installing platform-test to $PLUGIN_DIR"
|
||||
# ── create ./plugins/* from official dist packages ──
|
||||
PLUGIN_DIR="$ROOT/plugins"
|
||||
echo " → installing official plugins to $PLUGIN_DIR"
|
||||
|
||||
mkdir -p "$ROOT/plugins"
|
||||
|
||||
# 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"
|
||||
done
|
||||
|
||||
# Atomic replace: install to temp then rename
|
||||
TMP_DIR=$(mktemp -d "$ROOT/plugins/.platform-test-tmp.XXXXXX")
|
||||
cp -r "$DIST_PACKAGE/." "$TMP_DIR/"
|
||||
# Remove old directory (fix permissions first if needed)
|
||||
TMP_DIR=$(mktemp -d "$ROOT/.official-plugins-tmp.XXXXXX")
|
||||
cp -r "$DIST_ROOT/"* "$TMP_DIR/"
|
||||
# Remove old plugin directories (fix permissions first if needed)
|
||||
if [ -d "$PLUGIN_DIR" ]; then
|
||||
chmod -R u+rwx "$PLUGIN_DIR" 2>/dev/null || true
|
||||
rm -rf "$PLUGIN_DIR"
|
||||
|
|
@ -50,14 +50,12 @@ fi
|
|||
mv "$TMP_DIR" "$PLUGIN_DIR"
|
||||
|
||||
# ── verify ──
|
||||
if [ -f "$PLUGIN_DIR/plugin.json" ]; then
|
||||
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json')).get('id','unknown'))" 2>/dev/null || echo "unknown")
|
||||
if [ -d "$PLUGIN_DIR" ]; then
|
||||
FILE_COUNT=$(find "$PLUGIN_DIR" -type f | wc -l)
|
||||
echo " ✅ installed: $PLUGIN_DIR"
|
||||
echo " plugin id: $PLUGIN_ID"
|
||||
echo " files: $FILE_COUNT"
|
||||
else
|
||||
echo "❌ install failed: plugin.json missing in $PLUGIN_DIR"
|
||||
echo "❌ install failed: $PLUGIN_DIR missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -29,49 +29,21 @@ OFFICIAL="$VERSTAK_ROOT/verstak-official-plugins"
|
|||
if [ ! -d "$OFFICIAL" ]; then
|
||||
echo " ⚠️ verstak-official-plugins not found — skipping"
|
||||
else
|
||||
# npm deps
|
||||
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
|
||||
(cd "$OFFICIAL" && ./scripts/build.sh)
|
||||
echo " ✅ official plugins built"
|
||||
fi
|
||||
|
||||
# ── 3. Copy plugins to desktop ──
|
||||
# ── 3. Copy plugin packages to desktop ──
|
||||
echo ""
|
||||
echo "=== install plugins to desktop ==="
|
||||
DEST="$ROOT/plugins"
|
||||
rm -rf "$DEST"
|
||||
mkdir -p "$DEST"
|
||||
if [ -d "$OFFICIAL/plugins" ]; then
|
||||
cp -r "$OFFICIAL/plugins/"* "$DEST/" 2>/dev/null
|
||||
if [ -d "$OFFICIAL/dist" ] && [ -n "$(find "$OFFICIAL/dist" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)" ]; then
|
||||
cp -r "$OFFICIAL/dist/"* "$DEST/" 2>/dev/null
|
||||
echo " ✅ plugins copied to $DEST"
|
||||
else
|
||||
echo " ℹ️ no plugins to copy"
|
||||
echo " ℹ️ no plugin packages to copy"
|
||||
fi
|
||||
|
||||
# ── 4. Build desktop ──
|
||||
|
|
|
|||
Loading…
Reference in New Issue