From 0ac473d720ee5d2ff4a687186f64126d1765643f Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 20 Jun 2026 19:20:13 +0800 Subject: [PATCH] =?UTF-8?q?core:=20Milestone=207b=20=E2=80=94=20Files=20ex?= =?UTF-8?q?plorer=20and=20Default=20Editor=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Files plugin: richer explorer with breadcrumbs, selection, toolbar actions, rename/trash, filter, sorting, hidden/reserved entries filtered - Default Editor: line numbers, Ctrl+S, markdown toolbar, Edit/Preview/Split, markdown preview, Reload/Revert - E2E tests: 39 passed for files + editor - Workspace model: correction, naming alignment, compatibility wrappers - Updated docs: NOTES_FILES_PLUGIN_PLAN.md, PLUGIN_RUNTIME.md --- docs/NOTES_FILES_PLUGIN_PLAN.md | 23 +- docs/PLUGIN_RUNTIME.md | 165 ++- frontend/e2e/default-editor.spec.js | 61 + frontend/e2e/files-plugin.spec.js | 63 +- frontend/e2e/plugin-manager-layout.spec.js | 29 +- frontend/src/App.svelte | 14 +- frontend/src/lib/shell/WorkspaceHost.svelte | 15 +- frontend/src/lib/test/wails-mock.js | 497 ++++++-- frontend/src/lib/ui/Icon.svelte | 2 + frontend/vite.config.js | 19 + frontend/wailsjs/go/api/App.d.ts | 31 + frontend/wailsjs/go/api/App.js | 60 + frontend/wailsjs/go/models.ts | 143 +++ internal/api/app_test.go | 70 ++ internal/core/vault/vault.go | 5 +- internal/core/vault/vault_test.go | 66 +- internal/core/workspace/manager.go | 1196 +++++++++++-------- internal/core/workspace/manager_test.go | 658 +++++----- 18 files changed, 1956 insertions(+), 1161 deletions(-) diff --git a/docs/NOTES_FILES_PLUGIN_PLAN.md b/docs/NOTES_FILES_PLUGIN_PLAN.md index 9879e2f..181cf11 100644 --- a/docs/NOTES_FILES_PLUGIN_PLAN.md +++ b/docs/NOTES_FILES_PLUGIN_PLAN.md @@ -15,7 +15,7 @@ Already available: - Plugin discovery, lifecycle, settings, capabilities, bundled commands, and bundled frontend events. -- Workspace tree APIs for `space`, `case`, and `folder`. +- Workspace lifecycle APIs for top-level physical folders under the vault root. - Plugin-owned internal storage directories: `.verstak/plugin-data/`, `.verstak/plugin-settings/`, and `.verstak/plugin-cache/`. @@ -54,11 +54,11 @@ Canonical rules: Canonical scoped paths: -- Workspace/root overview notes live under `/Notes/`. -- Case/project/folder scoped notes live under `/Notes/`. -- The default overview note is `/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. +- Workspace overview notes live under `/Notes/`. +- The default overview note is `/Notes/Overview.md`. +- `` is the top-level physical folder name under the vault root. +- Files plugin workspace views are scoped with `workspaceRootPath`, which is the + selected top-level workspace folder name. Visibility requirements: @@ -67,10 +67,9 @@ Visibility requirements: - External file managers must show the same `.md` files. - Outside Verstak, the files must remain useful as normal Markdown. -The workspace tree can remain `space`/`case`/`folder`. Adding `note` as a -workspace node type is not part of the next milestone because it would require a -schema migration. The Notes service can index and manage Markdown files inside -canonical `Notes/` folders without changing workspace node types. +There is no canonical metadata workspace tree. Adding `note` as a workspace node +type is not part of the next milestone. The Notes service can index and manage +Markdown files inside canonical `Notes/` folders under each top-level workspace. ## Title To Filename Contract @@ -201,8 +200,8 @@ Files owns safe raw vault file access. Notes owns note semantics. The same physical note must be visible through both APIs: -- Files sees `SomeCase/Notes/Overview.md` as a file. -- Notes sees `SomeCase/Notes/Overview.md` as a note with title `Overview`. +- Files sees `Project/Notes/Overview.md` as a file. +- Notes sees `Project/Notes/Overview.md` as a note with title `Overview`. There must be no duplicate note content stored in plugin settings, plugin data, or a separate `.verstak` note database. Indexes and caches may exist later, but diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index ec69d8a..fc493d2 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -750,51 +750,104 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins. - `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json) - `./scripts/build.sh` — ✅ -## Workspace / Cases Core Capability +## Workspace Core Capability -Workspace — центральная модель Верстака вокруг "дел". Это НЕ notes/files — это фундамент. - -### Ноды - -| Поле | Тип | Описание | -|------|-----|----------| -| `id` | UUID | Стабильный идентификатор | -| `parentId` | string | ID родителя (пусто для root) | -| `type` | space/case/folder | Тип ноды | -| `title` | string | Название | -| `path` | string | Vault-relative папка ноды | -| `status` | active/sleeping/archived | Жизненный цикл | -| `tags` | string[] | Теги | -| `order` | int | Порядок среди siblings | -| `createdAt` | RFC3339Nano | Создан | -| `updatedAt` | RFC3339Nano | Обновлён | - -### Хранение - -`/.verstak/workspace.json` — атомарная запись metadata (temp + rename). -Каждая workspace node также имеет user-visible folder inside vault. `path` -хранит canonical vault-relative folder path. Имена папок читаемые: берутся из -title, очищаются от запрещённых символов, сохраняют Unicode/кириллицу, а при -коллизии получают suffix ` (2)`, ` (3)`, ... +Workspace — это физическая папка верхнего уровня внутри vault root. Filesystem +является source of truth для списка workspaces. Пример: ``` / - My Workspace/ - Test/ - test/ + Workspace/ + Notes/ + Overview.md + Project/ + ClientA/ + .verstak/ ``` +Нет единого `/Workspace/` контейнера для всех workspaces. Папка +`Workspace/` может быть обычным workspace, но `Project/` и `ClientA/` являются +такими же workspace на том же уровне. + +### Хранение + +Workspace existence/list хранится только в filesystem: + +- `ListWorkspaces()` читает top-level directories из vault root. +- `.verstak`, reserved/internal directories, top-level files и symlinks не + считаются workspaces. +- `.verstak/workspace*.json` не является source of truth для списка workspaces. +- Нет persisted workspace path mapping и нет virtual workspace tree, которое + мапится на произвольные папки. + +`.verstak` может хранить только metadata, которая не заменяет filesystem: + +- UI state: selected workspace, expanded folders, sort/pin state, preferences. +- Semantic snapshot: applied template snapshot, enabled feature areas, folder + conventions. + +Template snapshot копируется в metadata при создании workspace. Workspace +identity при этом остаётся именем top-level folder; `metadata.workspaceName` +является presentation/snapshot field, not canonical identity. Если сохранённое +значение расходится с именем папки, runtime возвращает canonical `workspaceName` +равным имени папки без filesystem side effects. + +```json +{ + "workspaceName": "Project", + "createdFromTemplate": { + "templateId": "client-project", + "templateName": "Client Project", + "templateVersion": 1, + "appliedAt": "2026-06-19T12:00:00Z" + }, + "features": { + "notes": true, + "files": true, + "secrets": true, + "activity": false + }, + "folders": { + "notes": "Notes", + "files": "Files", + "secrets": "Secrets" + } +} +``` + +Если original template удалён или изменён позже, существующий workspace +открывается по сохранённому snapshot и не мутирует автоматически. Template +update/migration может быть только явной future feature. Если metadata +отсутствует, workspace открывается как generic workspace минимум с `files: true`. + ### API -- `GetWorkspaceTree()` — полное дерево -- `CreateWorkspaceNode(parentID, type, title)` — создать -- `RenameWorkspaceNode(id, title)` — переименовать -- `MoveWorkspaceNode(id, newParentID)` — переместить -- `ArchiveWorkspaceNode(id)` — архивировать -- `SetCurrentWorkspaceNode(id)` — выбрать текущую -- `GetCurrentWorkspaceNode()` — получить текущую +- `ListWorkspaces()` — список top-level physical folders. +- `CreateWorkspace(name, templateId?)` — создать `//`, применить + template один раз, сохранить snapshot metadata. +- `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder + и обновить metadata key/name. +- `TrashWorkspace(name)` — перенести весь top-level workspace folder в internal + trash policy. +- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback. +- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на + существование workspace. + +Deprecated compatibility APIs: + +- `GetWorkspaceTree()` — flat view, derived from top-level folders. Не дерево. +- `CreateWorkspaceNode(...)` — wrapper over `CreateWorkspace`. +- `RenameWorkspaceNode(...)` — wrapper over `RenameWorkspace`. +- `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`. +- `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected. +- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over + selected top-level workspace UI state. + +Эти методы существуют только для постепенного frontend/Wails cleanup. Они не +должны создавать или сохранять nested workspace tree и не должны восстанавливать +`WorkspaceNode.path` mapping. ### Capability @@ -802,28 +855,16 @@ title, очищаются от запрещённых символов, сохр ### Правила -- 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 - -### Типы нод - -| Тип | Назначение | -|-----|-----------| -| `space` | Рабочее пространство (root) | -| `case` | Дело | -| `folder` | Папка | - -НЕ добавляются: note, file, action, secret, worklog, link — это плагины. +- Workspace name — один safe folder name, не path. +- Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte, + `.verstak`, reserved/internal names, symlink workspaces, conflicts. +- WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки + (`Project`, `ClientA`, etc). Files plugin показывает именно эту папку. +- Files API остаётся raw vault-relative API: `Project/Notes/Overview.md`, + `Project/docs/file.md`, `Test/readme.md`. +- Notes are ordinary Markdown files under `/Notes/`; нет + `.verstak/notes`, UUID note storage или второго source of truth для note + content. ### Lifecycle Events @@ -837,11 +878,11 @@ title, очищаются от запрещённых символов, сохр ### UI -WorkspaceTree в sidebar: -- Дерево с expand/collapse -- Создание case/folder -- Выбор текущей ноды -- Индикатор статуса (active/archived/sleeping) +Workspace list в sidebar: +- Flat list of top-level workspace folders. +- Create workspace, rename workspace, trash workspace. +- Selection is stored as selected workspace name. +- No expand/collapse workspace tree and no case/folder node creation in core. --- diff --git a/frontend/e2e/default-editor.spec.js b/frontend/e2e/default-editor.spec.js index fe63b3e..f9ae676 100644 --- a/frontend/e2e/default-editor.spec.js +++ b/frontend/e2e/default-editor.spec.js @@ -36,6 +36,67 @@ test.describe('F: Default Editor Plugin', () => { await expect(textarea).toHaveValue('Buy groceries\nWrite tests'); }); + test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => { + await page.evaluate(async () => { + const err = await window.go.api.App.WriteVaultTextFile( + 'verstak.platform-test', + 'Project/Notes/editing.md', + '# Editing\n\nplain text', + { createIfMissing: true, overwrite: true } + ); + if (err) throw new Error(err); + const [result, openErr] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', { + kind: 'vault-file', + path: 'Project/Notes/editing.md', + extension: '.md', + context: { sourceView: 'files', isInsideNotesFolder: true, notesMode: true }, + }); + if (openErr) throw new Error(openErr); + window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result })); + }); + + const editor = page.locator('[data-editor-mode="notes-markdown"]'); + await expect(editor).toBeVisible({ timeout: 10000 }); + await expect(editor.locator('[data-notes-badge]')).toBeVisible(); + + await editor.locator('[data-editor-mode-button="edit"]').click(); + const textarea = editor.locator('[data-editor-textarea]'); + await expect(textarea).toBeVisible(); + await textarea.fill('plain text'); + await textarea.selectText(); + await editor.locator('[data-md-action="bold"]').click(); + await expect(textarea).toHaveValue('**plain text**'); + + await editor.locator('[data-md-action="heading"]').click(); + await expect(textarea).toHaveValue('# **plain text**'); + await expect(editor.locator('[data-save-state]')).toContainText('Modified'); + + await editor.locator('[data-editor-mode-button="split"]').click(); + await expect(editor.locator('[data-editor-textarea]')).toBeVisible(); + await expect(editor.locator('[data-preview]')).toBeVisible(); + await expect(editor.locator('[data-preview]')).toContainText('plain text'); + + await textarea.press(process.platform === 'darwin' ? 'Meta+S' : 'Control+S'); + await expect(editor.locator('[data-save-state]')).toContainText('Saved'); + + await textarea.fill('discard me'); + page.once('dialog', (dialog) => dialog.accept()); + await editor.locator('[data-editor-action="reload"]').click(); + await expect(textarea).toHaveValue('# **plain text**'); + + await page.evaluate(async () => { + const [result, openErr] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', { + kind: 'vault-file', + path: 'Project/Notes/editing.md', + extension: '.md', + context: { sourceView: 'files', isInsideNotesFolder: true, notesMode: true }, + }); + if (openErr) throw new Error(openErr); + window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result })); + }); + await expect(page.locator('[data-editor-mode="notes-markdown"] [data-preview]')).toContainText('plain text', { timeout: 10000 }); + }); + test('open .md file outside Notes routes to highest-priority provider', async ({ page }) => { await page.evaluate(async () => { const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', { diff --git a/frontend/e2e/files-plugin.spec.js b/frontend/e2e/files-plugin.spec.js index 04cc7cb..b005927 100644 --- a/frontend/e2e/files-plugin.spec.js +++ b/frontend/e2e/files-plugin.spec.js @@ -28,16 +28,65 @@ test.describe('G: Files Plugin', () => { }); test('workspace Files view is scoped to selected workspace folder', async ({ page }) => { - await page.locator('.wt-label').filter({ hasText: 'Alpha Case' }).click(); + await page.locator('.wt-label').filter({ hasText: 'Project' }).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 expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.files-item-name').filter({ hasText: 'test-only.txt' })).toHaveCount(0); - await page.locator('.wt-label').filter({ hasText: 'Beta Case' }).click(); + await page.locator('.wt-label').filter({ hasText: 'Test' }).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); + await expect(page.locator('.files-item-name').filter({ hasText: 'test-only.txt' })).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toHaveCount(0); + }); + + test('files explorer supports create navigate rename filter sort open and trash', async ({ page }) => { + await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); + await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); + + await page.locator('[data-files-action="new-folder"]').click(); + await page.locator('[data-files-create-input]').fill('Daily'); + await page.locator('[data-files-create-confirm]').click(); + await expect(page.locator('[data-file-name="Daily"]')).toBeVisible(); + + await page.locator('[data-file-name="Daily"]').dblclick(); + await expect(page.locator('.files-breadcrumb')).toContainText('Daily'); + + await page.locator('[data-files-action="new-markdown"]').click(); + await page.locator('[data-files-create-input]').fill('Log.md'); + await page.locator('[data-files-create-confirm]').click(); + await expect(page.locator('[data-file-name="Log.md"]')).toBeVisible(); + + await page.locator('[data-file-name="Log.md"]').click(); + await page.locator('[data-files-action="rename"]').click(); + await page.locator('[data-files-rename-input]').fill('Journal.md'); + await page.locator('[data-files-rename-confirm]').click(); + await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible(); + await expect(page.locator('[data-file-name="Log.md"]')).toHaveCount(0); + + await page.locator('[data-files-filter]').fill('journ'); + await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible(); + await expect(page.locator('[data-file-name="project-only.txt"]')).toHaveCount(0); + await page.locator('[data-files-filter]').fill(''); + + await page.locator('[data-files-sort]').selectOption('modified-desc'); + await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible(); + + await page.locator('[data-file-name="Journal.md"]').dblclick(); + await expect(page.locator('[data-editor-mode="generic-markdown"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-resource-path="Project/Daily/Journal.md"]')).toBeVisible(); + + await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); + await expect(page.locator('[data-file-name="Daily"]')).toBeVisible({ timeout: 10000 }); + await page.locator('[data-file-name="Daily"]').dblclick(); + await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible({ timeout: 10000 }); + await page.locator('[data-file-name="Journal.md"]').click(); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('[data-files-action="trash"]').click(); + await expect(page.locator('[data-file-name="Journal.md"]')).toHaveCount(0); + + await page.locator('[data-files-action="up"]').click(); + await expect(page.locator('.files-breadcrumb')).not.toContainText('Daily'); }); test('open .txt via workbench from files context shows default-editor', async ({ page }) => { @@ -104,7 +153,7 @@ test.describe('G: Files Plugin', () => { test('files plugin card shows openProviders in contributions', async ({ page }) => { await page.evaluate(async () => { const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', { - kind: 'vault-file', path: 'test.txt', extension: '.txt', + kind: 'vault-file', path: 'Docs/todo.txt', extension: '.txt', context: { sourcePluginId: 'verstak.files', sourceView: 'files' }, }); if (err) throw new Error(err); diff --git a/frontend/e2e/plugin-manager-layout.spec.js b/frontend/e2e/plugin-manager-layout.spec.js index 57bf7f4..1ff6958 100644 --- a/frontend/e2e/plugin-manager-layout.spec.js +++ b/frontend/e2e/plugin-manager-layout.spec.js @@ -74,12 +74,33 @@ test.describe('E: Plugin Manager layout', () => { test('workspace selection keeps exactly one active node', async ({ page }) => { const selected = page.locator('.wt-node.selected .wt-label'); await expect(selected).toHaveCount(1); - await expect(selected).toHaveText('Alpha Case'); + await expect(selected).toHaveText('Project'); - await page.locator('.wt-label').filter({ hasText: 'Beta Case' }).click(); + await page.locator('.wt-label').filter({ hasText: 'Test' }).click(); await expect(selected).toHaveCount(1); - await expect(selected).toHaveText('Beta Case'); + await expect(selected).toHaveText('Test'); + }); + + test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => { + await page.locator('button[title="New workspace"]').click(); + await page.locator('.wt-create input').fill('ClientA'); + await page.locator('.wt-btn-primary', { hasText: 'Create' }).click(); + + await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible(); + + const client = page.locator('.wt-node').filter({ hasText: 'ClientA' }); + await client.locator('button[title="Rename workspace"]').click(); + await page.locator('.wt-rename').fill('ClientB'); + await page.locator('button[title="Save rename"]').click(); + + await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toBeVisible(); + await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0); + + const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' }); + await renamed.locator('button[title="Trash workspace"]').click(); + + await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0); }); test('shell icons render through bundled Lucide SVG components', async ({ page }) => { @@ -87,7 +108,7 @@ test.describe('E: Plugin Manager layout', () => { await expect(logo).toBeVisible(); await expect(logo).toHaveClass(/lucide/); - await page.locator('.wt-label').filter({ hasText: 'Alpha Case' }).click(); + await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); const workspaceIcon = page.locator('.wt-node-icon').first(); await expect(workspaceIcon).toBeVisible(); await expect(workspaceIcon).toHaveClass(/lucide/); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index e3b5e0b..5367d47 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -22,7 +22,7 @@ let openedResource = null; let workspaceNodes = []; - let currentWorkspaceNodeId = ''; + let selectedWorkspaceName = ''; function flog(msg) { App.WriteFrontendLog('App', msg); @@ -95,11 +95,11 @@ currentView = 'workbench'; } - function onWorkspaceNodeSelected(e) { - debug.log('[App] onWorkspaceNodeSelected:', e.detail?.nodeId); - currentWorkspaceNodeId = e.detail?.nodeId || ''; + function onWorkspaceSelected(e) { + debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName); + selectedWorkspaceName = e.detail?.workspaceName || ''; workspaceNodes = e.detail?.nodes || workspaceNodes; - if (currentWorkspaceNodeId) { + if (selectedWorkspaceName) { currentView = 'workspace'; } } @@ -118,7 +118,7 @@ window.addEventListener('verstak:open-settings', onOpenSettings); window.addEventListener('verstak:close-settings', onCloseSettings); window.addEventListener('verstak:workbench-opened', onWorkbenchOpened); - window.addEventListener('verstak:workspace-node-selected', onWorkspaceNodeSelected); + window.addEventListener('verstak:workspace-selected', onWorkspaceSelected); } onMount(() => { checkVault(); }); @@ -140,7 +140,7 @@ {:else if currentView === 'workbench'} {:else if currentView === 'workspace'} - + {:else} {/if} diff --git a/frontend/src/lib/shell/WorkspaceHost.svelte b/frontend/src/lib/shell/WorkspaceHost.svelte index 2341b4f..6eb9fe2 100644 --- a/frontend/src/lib/shell/WorkspaceHost.svelte +++ b/frontend/src/lib/shell/WorkspaceHost.svelte @@ -2,15 +2,16 @@ import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte'; import * as App from '../../../wailsjs/go/api/App'; - export let currentNodeId = ''; + export let selectedWorkspaceName = ''; export let nodes = []; let contributions = {}; let plugins = []; let workspaceTools = []; - $: currentNode = nodes.find(n => n.id === currentNodeId) || null; - $: if (currentNodeId) loadTools(); + $: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null; + $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; + $: if (selectedWorkspaceName) loadTools(); async function loadTools() { try { @@ -33,10 +34,10 @@
- {#if currentNode} + {#if selectedWorkspace}
- {currentNode.title} - {currentNode.type} + {selectedWorkspace.title} + {selectedWorkspace.type}
{#if workspaceTools.length > 0} @@ -51,7 +52,7 @@
diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index 4e333b8..d9f9bf1 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -124,7 +124,7 @@ icon: 'folder', provides: ['verstak/files/v1'], requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'], - permissions: ['files.read', 'files.write', 'workbench.open', 'ui.register'], + permissions: ['files.read', 'files.write', 'files.delete', 'workbench.open', 'ui.register'], frontend: { entry: 'frontend/dist/index.js' }, contributes: { views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }], @@ -152,11 +152,10 @@ function makeDefaultWorkspaceTree() { return { status: 'initialized', - currentNodeId: 'case-alpha', + currentNodeId: 'Project', nodes: [ - { 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 } + { id: 'Project', parentId: '', type: 'space', title: 'Project', name: 'Project', rootPath: 'Project', status: 'active', order: 1 }, + { id: 'Test', parentId: '', type: 'space', title: 'Test', name: 'Test', rootPath: 'Test', status: 'active', order: 2 } ] }; } @@ -169,6 +168,16 @@ }; } + function listWorkspacesFromTree() { + return workspaceTree.nodes + .filter(function (n) { return !n.parentId; }) + .map(function (n) { return { name: n.name || n.id, rootPath: n.rootPath || n.name || n.id }; }); + } + + function makeWorkspaceNode(name, order) { + return { id: name, parentId: '', type: 'space', title: name, name: name, rootPath: name, status: 'active', order: order }; + } + function makeDefaultVaultFiles() { return { '': { type: 'folder', modifiedAt: new Date().toISOString() }, @@ -177,11 +186,12 @@ '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() }, - '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() } + 'Project': { type: 'folder', modifiedAt: new Date().toISOString() }, + 'Project/Notes': { type: 'folder', modifiedAt: new Date().toISOString() }, + 'Project/Notes/Overview.md': { type: 'file', content: '# Project Overview\n', modifiedAt: new Date().toISOString() }, + 'Project/project-only.txt': { type: 'file', content: 'project file', modifiedAt: new Date().toISOString() }, + 'Test': { type: 'folder', modifiedAt: new Date().toISOString() }, + 'Test/test-only.txt': { type: 'file', content: 'test file', modifiedAt: new Date().toISOString() } }; } @@ -380,122 +390,275 @@ } function defaultEditorBundle() { - return [ - '(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||{};', - 'var path=req.path||"";', - 'var mode=req.mode||"view";', - 'var ctx=req.context||{};', - 'var isNotes=ctx.notesMode||ctx.isInsideNotesFolder;', - 'var ext=(req.extension||"").toLowerCase();', - 'var isMd=ext===".md"||ext===".markdown";', - 'var editorMode=isNotes?"notes-markdown":isMd?"generic-markdown":"text";', - 'c.setAttribute("data-editor-mode",editorMode);', - 'c.setAttribute("data-resource-path",path);', - 'c.setAttribute("data-request-mode",mode);', - 'var toolbar=document.createElement("div");', - 'toolbar.className="de-toolbar";', - 'var modeLabel=document.createElement("span");', - 'modeLabel.className="de-toolbar-mode";', - 'modeLabel.textContent=editorMode;', - 'toolbar.appendChild(modeLabel);', - 'var pathLabel=document.createElement("span");', - 'pathLabel.className="de-toolbar-context";', - 'pathLabel.textContent=path;', - 'toolbar.appendChild(pathLabel);', - 'if(isNotes){var badge=document.createElement("span");badge.className="de-notes-badge";badge.textContent="notes context";badge.setAttribute("data-notes-badge","");toolbar.appendChild(badge);}', - 'c.appendChild(toolbar);', - 'var content=document.createElement("div");', - 'content.className="de-editor-wrap";', - 'content.textContent="Loading...";', - 'c.appendChild(content);', - 'api.files.readText(path).then(function(text){', - 'content.textContent="";', - 'if(isMd){', - 'var preview=document.createElement("div");', - 'preview.className="de-preview";', - 'preview.setAttribute("data-preview","");', - 'preview.textContent=text;', - 'content.appendChild(preview);', - '}else{', - 'var ta=document.createElement("textarea");', - 'ta.className="de-textarea";', - 'ta.value=text;', - 'ta.setAttribute("data-editor-textarea","");', - 'content.appendChild(ta);', - '}', - '}).catch(function(err){', - 'content.textContent="Error: "+(err.message||err);', - '});', - '},', - 'unmount:function(c){c.innerHTML="";}', - '};', - 'window.VerstakPluginRegister("verstak.default-editor",{components:{DefaultEditor:DefaultEditor}});', - '})();' - ].join('\n'); + return '(' + function () { + function e(tag, attrs, children) { + var node = document.createElement(tag); + attrs = attrs || {}; + Object.keys(attrs).forEach(function (key) { + if (key === 'className') node.className = attrs[key]; + else if (key.indexOf('on') === 0) node.addEventListener(key.slice(2).toLowerCase(), attrs[key]); + else node.setAttribute(key, attrs[key]); + }); + (children || []).forEach(function (child) { node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); }); + return node; + } + function esc(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } + function renderMarkdown(text) { + return String(text || '').split(/\n/).map(function (line) { + if (/^#\s+/.test(line)) return '

' + esc(line.replace(/^#\s+/, '')) + '

'; + if (/^-\s+\[[ x]\]\s+/i.test(line)) return '
  • ' + esc(line.replace(/^-\s+\[[ x]\]\s+/i, '')) + '
'; + if (/^-\s+/.test(line)) return '
  • ' + esc(line.replace(/^-\s+/, '')) + '
'; + return line ? '

' + esc(line).replace(/\*\*(.+?)\*\*/g, '$1') + '

' : ''; + }).join(''); + } + function insertAround(ta, before, after, fallback) { + var start = ta.selectionStart; + var end = ta.selectionEnd; + var text = ta.value.slice(start, end) || fallback || ''; + ta.value = ta.value.slice(0, start) + before + text + after + ta.value.slice(end); + ta.selectionStart = start + before.length; + ta.selectionEnd = start + before.length + text.length; + ta.dispatchEvent(new Event('input', { bubbles: true })); + } + 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,.de-md-toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-wrap:wrap}.de-toolbar-mode{font-size:.75rem;color:#4ecca3;padding:.15rem .5rem;border-radius:3px;background:#1a2a3a}.de-toolbar-context{font-size:.75rem;color:#8b8ba8}.de-toolbar-spacer{flex:1}.de-toolbar-btn,.de-md-btn{font-size:.75rem;padding:.25rem .6rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc}.de-toolbar-btn.active{border-color:#4ecca3;color:#4ecca3}.de-status.dirty{color:#f39c12}.de-status.saved{color:#4ecca3}.de-editor-wrap{flex:1;display:flex;min-height:0;overflow:hidden}.de-pane{flex:1;display:flex;min-width:0}.de-pane+.de-pane{border-left:1px solid #16213e}.de-lines{padding:.75rem .4rem;background:#0a0a15;color:#555;font-family:monospace;line-height:1.6;white-space:pre}.de-textarea{flex:1;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;padding:1rem;overflow:auto;background:#0d0d1a;color:#ddd}.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 || {}; + var path = req.path || ''; + var ctx = req.context || {}; + var isNotes = ctx.notesMode || ctx.isInsideNotesFolder; + var ext = (req.extension || '').toLowerCase(); + var isMd = ext === '.md' || ext === '.markdown'; + var editorMode = isNotes ? 'notes-markdown' : isMd ? 'generic-markdown' : 'text'; + var viewMode = isMd && req.mode !== 'edit' ? 'preview' : 'edit'; + var current = ''; + var saved = ''; + var dirty = false; + var ta = null; + var preview = null; + var status = e('span', { className: 'de-status', 'data-save-state': '' }, []); + c.setAttribute('data-editor-mode', editorMode); + c.setAttribute('data-resource-path', path); + c.setAttribute('data-request-mode', req.mode || 'view'); + var toolbar = e('div', { className: 'de-toolbar' }, [e('span', { className: 'de-toolbar-mode' }, [editorMode]), e('span', { className: 'de-toolbar-context' }, [path])]); + if (isNotes) toolbar.appendChild(e('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, ['notes context'])); + toolbar.appendChild(e('span', { className: 'de-toolbar-spacer' }, [])); + ['edit', 'preview', 'split'].forEach(function (mode) { + if (!isMd) return; + toolbar.appendChild(e('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': mode, onClick: function () { viewMode = mode; rebuild(); } }, [mode[0].toUpperCase() + mode.slice(1)])); + }); + toolbar.appendChild(e('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload', onClick: reload }, ['Reload'])); + toolbar.appendChild(e('button', { className: 'de-toolbar-btn', onClick: save }, ['Save'])); + toolbar.appendChild(status); + c.appendChild(toolbar); + if (isMd) { + var md = e('div', { className: 'de-md-toolbar' }, []); + [['heading', 'H'], ['bold', 'B'], ['italic', 'I'], ['link', 'Link'], ['code', 'Code'], ['code-block', '```'], ['bullet', 'List'], ['numbered', '1.'], ['quote', 'Quote'], ['task', 'Task']].forEach(function (item) { + md.appendChild(e('button', { className: 'de-md-btn', 'data-md-action': item[0], onClick: function () { mdAction(item[0]); } }, [item[1]])); + }); + c.appendChild(md); + } + var wrap = e('div', { className: 'de-editor-wrap' }, []); + c.appendChild(wrap); + function setStatus(text, cls) { status.textContent = text; status.className = 'de-status ' + (cls || ''); } + function update() { dirty = current !== saved; setStatus(dirty ? 'Modified' : 'Saved', dirty ? 'dirty' : 'saved'); if (preview) preview.innerHTML = renderMarkdown(current); } + function makeEditor() { + var pane = e('div', { className: 'de-pane' }, []); + var lines = e('div', { className: 'de-lines' }, []); + ta = e('textarea', { className: 'de-textarea', 'data-editor-textarea': '', spellcheck: 'false' }, []); + ta.value = current; + function renumber() { lines.textContent = Array.from({ length: ta.value.split('\n').length }, function (_, i) { return i + 1; }).join('\n'); } + ta.addEventListener('input', function () { current = ta.value; renumber(); update(); }); + ta.addEventListener('keydown', function (ev) { if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 's') { ev.preventDefault(); save(); } if (ev.key === 'Tab') { ev.preventDefault(); insertAround(ta, ' ', '', ''); } }); + renumber(); + pane.appendChild(lines); + pane.appendChild(ta); + return pane; + } + function makePreview() { preview = e('div', { className: 'de-preview', 'data-preview': '' }, []); preview.innerHTML = renderMarkdown(current); return e('div', { className: 'de-pane' }, [preview]); } + function rebuild() { + wrap.innerHTML = ''; + ta = null; + preview = null; + if (!isMd || viewMode === 'edit' || viewMode === 'split') wrap.appendChild(makeEditor()); + if (isMd && (viewMode === 'preview' || viewMode === 'split')) wrap.appendChild(makePreview()); + Array.from(toolbar.querySelectorAll('[data-editor-mode-button]')).forEach(function (btn) { btn.className = 'de-toolbar-btn' + (btn.getAttribute('data-editor-mode-button') === viewMode ? ' active' : ''); }); + update(); + } + function save() { + return api.files.writeText(path, current, { createIfMissing: false, overwrite: true }).then(function () { saved = current; dirty = false; setStatus('Saved', 'saved'); }); + } + function reload() { + if (dirty && !window.confirm('Discard unsaved changes and reload from disk?')) return; + api.files.readText(path).then(function (text) { current = text || ''; saved = current; dirty = false; rebuild(); }); + } + function mdAction(action) { + if (!ta) { viewMode = 'edit'; rebuild(); } + if (action === 'heading') insertAround(ta, '# ', '', ''); + else if (action === 'bold') insertAround(ta, '**', '**', 'bold text'); + else if (action === 'italic') insertAround(ta, '*', '*', 'italic text'); + else if (action === 'link') insertAround(ta, '[', '](https://)', 'link text'); + else if (action === 'code') insertAround(ta, '`', '`', 'code'); + else if (action === 'code-block') insertAround(ta, '```\n', '\n```', 'code'); + else if (action === 'bullet') insertAround(ta, '- ', '', 'item'); + else if (action === 'numbered') insertAround(ta, '1. ', '', 'item'); + else if (action === 'quote') insertAround(ta, '> ', '', 'quote'); + else if (action === 'task') insertAround(ta, '- [ ] ', '', 'task'); + } + reload(); + }, + unmount: function (c) { c.innerHTML = ''; } + }; + window.VerstakPluginRegister('verstak.default-editor', { components: { DefaultEditor: DefaultEditor } }); + }.toString() + ')();'; } function filesPluginBundle() { - return [ - "(function(){", - "var FilesView={", - "mount:function(c,p,api){", - "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(root).then(function(entries){", - "list.innerHTML='';", - "if(!entries||!entries.length){list.textContent='Empty folder';return;}", - "entries.forEach(function(e){", - "if(e.isHidden||e.isReserved)return;", - "var item=document.createElement('div');", - "item.className='files-item';", - "item.setAttribute('data-file-name',e.name);", - "item.setAttribute('data-file-type',e.type);", - "item.setAttribute('data-file-path',e.relativePath);", - "var icon=document.createElement('span');", - "icon.className='files-item-icon';", - "icon.textContent=e.type==='folder'?'[D]':'[F]';", - "var name=document.createElement('span');", - "name.className='files-item-name';", - "name.textContent=e.name;", - "item.appendChild(icon);", - "item.appendChild(name);", - "if(e.type!=='folder'){", - "item.addEventListener('dblclick',function(){", - "var ext=e.extension?'.'+e.extension:'';", - "var ctx={sourcePluginId:'verstak.files',sourceView:'files'};", - "api.workbench.openResource({kind:'vault-file',path:e.relativePath,mode:'view',extension:ext,context:ctx});", - "});", - "}", - "list.appendChild(item);", - "});", - "}).catch(function(err){list.textContent='Error: '+(err.message||err);});", - "}", - "load();", - "},", - "unmount:function(c){c.innerHTML='';}", - "};", - "window.VerstakPluginRegister('verstak.files',{components:{FilesView:FilesView}});", - "})();" - ].join('\n'); + return '(' + function () { + function e(tag, attrs, children) { + var node = document.createElement(tag); + attrs = attrs || {}; + Object.keys(attrs).forEach(function (key) { + if (key === 'className') node.className = attrs[key]; + else if (key.indexOf('on') === 0) node.addEventListener(key.slice(2).toLowerCase(), attrs[key]); + else node.setAttribute(key, attrs[key]); + }); + (children || []).forEach(function (child) { if (child) node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); }); + return node; + } + function clean(path) { return String(path || '').split('/').filter(Boolean).join('/'); } + function parent(path) { path = clean(path); var i = path.lastIndexOf('/'); return i < 0 ? '' : path.slice(0, i); } + function base(path) { path = clean(path); var i = path.lastIndexOf('/'); return i < 0 ? path : path.slice(i + 1); } + function ext(name) { var i = String(name || '').lastIndexOf('.'); return i > 0 ? name.slice(i + 1).toLowerCase() : ''; } + var FilesView = { + mount: function (c, p, api) { + c.innerHTML = ''; + c.className = 'files-root'; + c.setAttribute('tabindex', '0'); + c.setAttribute('data-plugin-id', 'verstak.files'); + var n = p && p.workspaceNode; + var root = clean((p && (p.workspaceRootPath || (n && (n.rootPath || n.name || n.id)))) || ''); + var workspaceName = root || 'Workspace'; + var current = ''; + var entries = []; + var selected = ''; + var filter = ''; + var sort = 'folder-name'; + var createMode = ''; + var renaming = null; + function scoped(local) { local = clean(local); return root ? (local ? root + '/' + local : root) : local; } + function local(full) { full = clean(full); return root && full.indexOf(root + '/') === 0 ? full.slice(root.length + 1) : full === root ? '' : full; } + var toolbar = e('div', { className: 'files-toolbar' }, []); + var breadcrumb = e('div', { className: 'files-breadcrumb' }, []); + function btn(label, action, fn) { return e('button', { className: 'files-toolbar-btn', 'data-files-action': action, onClick: fn }, [label]); } + toolbar.appendChild(breadcrumb); + toolbar.appendChild(btn('Up', 'up', function () { if (current) nav(parent(current)); })); + toolbar.appendChild(btn('Refresh', 'refresh', load)); + toolbar.appendChild(btn('+ Folder', 'new-folder', function () { startCreate('folder'); })); + toolbar.appendChild(btn('+ Markdown', 'new-markdown', function () { startCreate('markdown'); })); + toolbar.appendChild(btn('+ Text', 'new-text', function () { startCreate('text'); })); + toolbar.appendChild(btn('Open', 'open', function () { open(entryByPath(selected)); })); + toolbar.appendChild(btn('Rename', 'rename', function () { startRename(entryByPath(selected)); })); + toolbar.appendChild(btn('Trash', 'trash', function () { trash(entryByPath(selected)); })); + var filterInput = e('input', { className: 'files-filter', 'data-files-filter': '', placeholder: 'Filter current folder' }, []); + filterInput.addEventListener('input', function () { filter = filterInput.value.toLowerCase(); render(); }); + toolbar.appendChild(filterInput); + var sortSelect = e('select', { className: 'files-sort', 'data-files-sort': '' }, [ + e('option', { value: 'folder-name' }, ['Folders + name']), + e('option', { value: 'name-asc' }, ['Name']), + e('option', { value: 'type' }, ['Type']), + e('option', { value: 'modified-desc' }, ['Modified']), + e('option', { value: 'size-desc' }, ['Size']) + ]); + sortSelect.addEventListener('change', function () { sort = sortSelect.value; render(); }); + toolbar.appendChild(sortSelect); + c.appendChild(toolbar); + var list = e('div', { className: 'files-list', 'data-files-list': '' }, []); + c.appendChild(list); + var createPanel = e('div', { className: 'files-panel', style: 'display:none' }, []); + var createInput = e('input', { className: 'files-create-input', 'data-files-create-input': '' }, []); + createPanel.appendChild(createInput); + createPanel.appendChild(e('button', { className: 'files-toolbar-btn', 'data-files-create-confirm': '', onClick: confirmCreate }, ['Create'])); + createPanel.appendChild(e('button', { className: 'files-toolbar-btn', onClick: function () { createPanel.style.display = 'none'; } }, ['Cancel'])); + c.appendChild(createPanel); + var renamePanel = e('div', { className: 'files-panel', style: 'display:none' }, []); + var renameInput = e('input', { className: 'files-rename-input', 'data-files-rename-input': '' }, []); + renamePanel.appendChild(renameInput); + renamePanel.appendChild(e('button', { className: 'files-toolbar-btn', 'data-files-rename-confirm': '', onClick: confirmRename }, ['Rename'])); + renamePanel.appendChild(e('button', { className: 'files-toolbar-btn', onClick: function () { renamePanel.style.display = 'none'; } }, ['Cancel'])); + c.appendChild(renamePanel); + function entryByPath(path) { return entries.find(function (item) { return item.relativePath === path; }) || null; } + function updateBreadcrumb() { + breadcrumb.innerHTML = ''; + breadcrumb.appendChild(e('span', { className: 'files-breadcrumb-item', onClick: function () { nav(''); } }, [workspaceName])); + if (current) breadcrumb.appendChild(e('span', { className: 'files-breadcrumb-current' }, [' / ' + current])); + } + function visible() { + return entries.filter(function (item) { return !item.isHidden && !item.isReserved && (!filter || item.name.toLowerCase().indexOf(filter) !== -1); }).sort(function (a, b) { + if (sort === 'folder-name') { if (a.type === 'folder' && b.type !== 'folder') return -1; if (a.type !== 'folder' && b.type === 'folder') return 1; } + if (sort === 'modified-desc') return new Date(b.modifiedAt || 0) - new Date(a.modifiedAt || 0) || a.name.localeCompare(b.name); + if (sort === 'size-desc') return (b.size || 0) - (a.size || 0) || a.name.localeCompare(b.name); + if (sort === 'type') return (a.type + (a.extension || '')).localeCompare(b.type + (b.extension || '')) || a.name.localeCompare(b.name); + return a.name.localeCompare(b.name); + }); + } + function render() { + updateBreadcrumb(); + list.innerHTML = ''; + list.appendChild(e('div', { className: 'files-header' }, [e('span', {}, ['Name']), e('span', {}, ['Type']), e('span', {}, ['Size']), e('span', {}, ['Modified']), e('span', {}, ['Actions'])])); + visible().forEach(function (item) { + var row = e('div', { className: 'files-item' + (selected === item.relativePath ? ' selected' : ''), 'data-file-name': item.name, 'data-file-type': item.type, 'data-file-path': item.relativePath, onClick: function () { selected = item.relativePath; render(); }, onDblclick: function () { open(item); } }, []); + row.appendChild(e('span', { className: 'files-item-name' }, [item.name])); + row.appendChild(e('span', { className: 'files-item-meta' }, [item.type === 'folder' ? 'folder' : (item.extension || ext(item.name) || 'file')])); + row.appendChild(e('span', { className: 'files-item-meta' }, [item.size ? String(item.size) : ''])); + row.appendChild(e('span', { className: 'files-item-meta' }, [item.modifiedAt || ''])); + row.appendChild(e('span', { className: 'files-row-actions' }, [e('button', { className: 'files-row-btn', onClick: function (ev) { ev.stopPropagation(); open(item); } }, ['Open']), e('button', { className: 'files-row-btn', onClick: function (ev) { ev.stopPropagation(); startRename(item); } }, ['Rename']), e('button', { className: 'files-row-btn', onClick: function (ev) { ev.stopPropagation(); trash(item); } }, ['Trash'])])); + list.appendChild(row); + }); + } + function load() { selected = ''; api.files.list(scoped(current)).then(function (result) { entries = result || []; render(); }).catch(function (err) { list.textContent = 'Error: ' + (err.message || err); }); } + function nav(path) { current = clean(path); load(); } + function open(item) { + if (!item) return; + if (item.type === 'folder') { nav(local(item.relativePath)); return; } + var itemExt = item.extension ? '.' + item.extension : (ext(item.name) ? '.' + ext(item.name) : ''); + var ctx = { sourcePluginId: 'verstak.files', sourceView: 'files' }; + if ((itemExt === '.md' || itemExt === '.markdown') && local(item.relativePath).split('/')[0] === 'Notes') { ctx.isInsideNotesFolder = true; ctx.notesMode = true; } + api.workbench.openResource({ kind: 'vault-file', path: item.relativePath, mode: 'view', extension: itemExt, context: ctx }); + } + function startCreate(mode) { createMode = mode; createInput.value = ''; createPanel.style.display = 'flex'; createInput.focus(); } + function confirmCreate() { + var name = createInput.value.trim(); + var mode = createMode; + if (!name) return; + if (mode === 'markdown' && !/\.(md|markdown)$/i.test(name)) name += '.md'; + if (mode === 'text' && !/\.[^/.]+$/.test(name)) name += '.txt'; + var path = scoped(current ? current + '/' + name : name); + (mode === 'folder' ? api.files.createFolder(path) : api.files.writeText(path, '', { createIfMissing: true, overwrite: false })).then(function () { createPanel.style.display = 'none'; load(); }); + } + function startRename(item) { if (!item) return; renaming = item; renameInput.value = item.name; renamePanel.style.display = 'flex'; renameInput.focus(); renameInput.select(); } + function confirmRename() { + if (!renaming) return; + var to = parent(renaming.relativePath); + to = to ? to + '/' + renameInput.value.trim() : renameInput.value.trim(); + api.files.move(renaming.relativePath, to, { overwrite: false }).then(function () { renamePanel.style.display = 'none'; renaming = null; load(); }); + } + function trash(item) { if (!item || !window.confirm('Move "' + item.name + '" to trash?')) return; api.files.trash(item.relativePath).then(load); } + createInput.addEventListener('keydown', function (ev) { if (ev.key === 'Enter') confirmCreate(); }); + renameInput.addEventListener('keydown', function (ev) { if (ev.key === 'Enter') confirmRename(); }); + load(); + }, + unmount: function (c) { c.innerHTML = ''; } + }; + window.VerstakPluginRegister('verstak.files', { components: { FilesView: FilesView } }); + }.toString() + ')();'; } function platformTestBundle() { @@ -780,18 +943,92 @@ moving.forEach(function (path) { delete vaultFiles[path]; }); return Promise.resolve([{ originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString() }, '']); }, - GetCurrentWorkspaceNode: function () { return Promise.resolve(null); }, + ListWorkspaces: function () { + return Promise.resolve(listWorkspacesFromTree()); + }, + CreateWorkspace: function (name) { + var norm = normalizeVaultPath(name, false); + if (norm.error || norm.path !== String(name || '').trim() || norm.path.indexOf('/') !== -1) { + return Promise.resolve(norm.error || 'invalid-workspace-name'); + } + if (vaultFiles[norm.path]) return Promise.resolve('conflict: ' + norm.path); + vaultFiles[norm.path] = { type: 'folder', modifiedAt: new Date().toISOString() }; + vaultFiles[norm.path + '/Notes'] = { type: 'folder', modifiedAt: new Date().toISOString() }; + vaultFiles[norm.path + '/Notes/Overview.md'] = { type: 'file', content: '# Overview\n', modifiedAt: new Date().toISOString() }; + workspaceTree.nodes.push(makeWorkspaceNode(norm.path, workspaceTree.nodes.length + 1)); + return Promise.resolve({ name: norm.path, rootPath: norm.path }); + }, + RenameWorkspace: function (oldName, newName) { + var oldNorm = normalizeVaultPath(oldName, false); + var newNorm = normalizeVaultPath(newName, false); + if (oldNorm.error) return Promise.resolve(oldNorm.error); + if (newNorm.error || newNorm.path.indexOf('/') !== -1) return Promise.resolve(newNorm.error || 'invalid-workspace-name'); + if (!vaultFiles[oldNorm.path]) return Promise.resolve('not-found: ' + oldNorm.path); + if (vaultFiles[newNorm.path]) return Promise.resolve('conflict: ' + newNorm.path); + Object.keys(vaultFiles).filter(function (path) { + return path === oldNorm.path || path.indexOf(oldNorm.path + '/') === 0; + }).forEach(function (path) { + var suffix = path.slice(oldNorm.path.length); + vaultFiles[newNorm.path + suffix] = vaultFiles[path]; + delete vaultFiles[path]; + }); + workspaceTree.nodes = workspaceTree.nodes.map(function (n) { + if (n.id !== oldNorm.path) return n; + return makeWorkspaceNode(newNorm.path, n.order); + }); + if (workspaceTree.currentNodeId === oldNorm.path) workspaceTree.currentNodeId = newNorm.path; + return Promise.resolve(''); + }, + TrashWorkspace: function (name) { + var norm = normalizeVaultPath(name, false); + if (norm.error) return Promise.resolve(norm.error); + if (!vaultFiles[norm.path]) return Promise.resolve('not-found: ' + norm.path); + Object.keys(vaultFiles).filter(function (path) { + return path === norm.path || path.indexOf(norm.path + '/') === 0; + }).forEach(function (path) { delete vaultFiles[path]; }); + workspaceTree.nodes = workspaceTree.nodes.filter(function (n) { return n.id !== norm.path; }); + if (workspaceTree.currentNodeId === norm.path) workspaceTree.currentNodeId = workspaceTree.nodes[0] ? workspaceTree.nodes[0].id : ''; + return Promise.resolve({ originalPath: norm.path, trashPath: '.verstak/trash/workspaces/mock/' + norm.path, trashId: 'mock', deletedAt: new Date().toISOString() }); + }, + GetWorkspaceMetadata: function (name) { + var norm = normalizeVaultPath(name, false); + if (norm.error) return Promise.resolve(norm.error); + if (!vaultFiles[norm.path]) return Promise.resolve('not-found: ' + norm.path); + return Promise.resolve({ + workspaceName: norm.path, + features: { files: true }, + folders: { notes: 'Notes', files: 'Files' } + }); + }, + UpdateWorkspaceMetadata: function (name, patch) { + return Promise.resolve(Object.assign({ workspaceName: name, features: { files: true }, folders: { notes: 'Notes', files: 'Files' } }, patch || {})); + }, + GetCurrentWorkspace: function () { + var found = workspaceTree.nodes.find(function (n) { return n.id === workspaceTree.currentNodeId; }); + return Promise.resolve(found ? { name: found.name || found.id, rootPath: found.rootPath || found.name || found.id } : null); + }, + GetCurrentWorkspaceNode: function () { + var found = workspaceTree.nodes.find(function (n) { return n.id === workspaceTree.currentNodeId; }); + return Promise.resolve(found ? Object.assign({}, found) : null); + }, GetWorkspaceTree: function () { return Promise.resolve(cloneWorkspaceTree()); }, - ArchiveWorkspaceNode: function () { return Promise.resolve(''); }, - CreateWorkspaceNode: function () { return Promise.resolve({}); }, + ArchiveWorkspaceNode: function (id) { return this.TrashWorkspace(id).then(function (response) { return typeof response === 'string' ? response : ''; }); }, + CreateWorkspaceNode: function (parentId, nodeType, title) { + return this.CreateWorkspace(title, 'default').then(function (response) { + if (typeof response === 'string') return { error: response }; + var ws = response; + return makeWorkspaceNode(ws.name, workspaceTree.nodes.length); + }); + }, MoveWorkspaceNode: function () { return Promise.resolve(''); }, - RenameWorkspaceNode: function () { return Promise.resolve(''); }, - SetCurrentWorkspaceNode: function (id) { + RenameWorkspaceNode: function (id, title) { return this.RenameWorkspace(id, title); }, + SetCurrentWorkspace: function (id) { var found = workspaceTree.nodes.some(function (n) { return n.id === id; }); - if (!found) return Promise.resolve('workspace node not found: ' + id); + if (!found) return Promise.resolve('workspace not found: ' + id); workspaceTree.currentNodeId = id; return Promise.resolve(''); }, + SetCurrentWorkspaceNode: function (id) { return this.SetCurrentWorkspace(id); }, SelectDirectory: function () { return Promise.resolve(''); }, SelectVaultForOpen: function () { return Promise.resolve(''); }, CreateVault: function () { return Promise.resolve(null); }, @@ -962,7 +1199,7 @@ icon: 'folder', provides: ['verstak/files/v1'], requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'], - permissions: ['files.read', 'files.write', 'workbench.open', 'ui.register'], + permissions: ['files.read', 'files.write', 'files.delete', 'workbench.open', 'ui.register'], frontend: { entry: 'frontend/dist/index.js' }, contributes: { views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }], diff --git a/frontend/src/lib/ui/Icon.svelte b/frontend/src/lib/ui/Icon.svelte index 5567960..1363da9 100644 --- a/frontend/src/lib/ui/Icon.svelte +++ b/frontend/src/lib/ui/Icon.svelte @@ -25,6 +25,7 @@ import Settings from 'lucide-svelte/icons/settings'; import Shield from 'lucide-svelte/icons/shield'; import TriangleAlert from 'lucide-svelte/icons/triangle-alert'; + import Trash2 from 'lucide-svelte/icons/trash-2'; export let name = 'dot'; export let size = 16; @@ -43,6 +44,7 @@ plugin: Plug, puzzle: Puzzle, space: LayoutGrid, + trash: Trash2, vault: Shield, warning: TriangleAlert, }; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 4b490cf..de13ef2 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -14,5 +14,24 @@ export default defineConfig(({ mode }) => { port: isTest ? 5174 : 5173, strictPort: true, }, + optimizeDeps: { + include: [ + 'lucide-svelte/icons/briefcase', + 'lucide-svelte/icons/chevron-down', + 'lucide-svelte/icons/chevron-right', + 'lucide-svelte/icons/circle', + 'lucide-svelte/icons/flask-conical', + 'lucide-svelte/icons/folder', + 'lucide-svelte/icons/layout-grid', + 'lucide-svelte/icons/panels-top-left', + 'lucide-svelte/icons/pencil', + 'lucide-svelte/icons/plug', + 'lucide-svelte/icons/puzzle', + 'lucide-svelte/icons/settings', + 'lucide-svelte/icons/shield', + 'lucide-svelte/icons/trash-2', + 'lucide-svelte/icons/triangle-alert', + ], + }, }; }); diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index fdb9bbc..1d71584 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -1,5 +1,6 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +import {workspace} from '../models'; import {workbench} from '../models'; import {capability} from '../models'; import {api} from '../models'; @@ -15,6 +16,8 @@ export function CreateVault(arg1:string):Promise; export function CreateVaultFolder(arg1:string,arg2:string):Promise; +export function CreateWorkspace(arg1:string,arg2:string):Promise; + export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise>; export function DisablePlugin(arg1:string):Promise; @@ -31,6 +34,8 @@ export function GetCapabilities():Promise>; export function GetContributions():Promise; +export function GetCurrentWorkspace():Promise>; + export function GetCurrentWorkspaceNode():Promise>; export function GetPermissions():Promise>; @@ -53,12 +58,16 @@ export function GetWorkbenchOpenedResources():Promise; +export function GetWorkspaceMetadata(arg1:string):Promise; + export function GetWorkspaceTree():Promise>; export function ListPluginCapabilities(arg1:string):Promise|string>; export function ListVaultFiles(arg1:string,arg2:string):Promise|string>; +export function ListWorkspaces():Promise|string>; + export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise; export function MoveWorkspaceNode(arg1:string,arg2:string):Promise; @@ -81,24 +90,46 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise export function ReloadPlugins():Promise; +export function RenameWorkspace(arg1:string,arg2:string):Promise; + export function RenameWorkspaceNode(arg1:string,arg2:string):Promise; +export function ResetSyncKey():Promise; + export function SelectDirectory():Promise; export function SelectVaultForOpen():Promise; export function SetCurrentVault(arg1:string):Promise; +export function SetCurrentWorkspace(arg1:string):Promise; + export function SetCurrentWorkspaceNode(arg1:string):Promise; export function SubscribePluginEvent(arg1:string,arg2:string):Promise; +export function SyncConfigure(arg1:string,arg2:string,arg3:string):Promise; + +export function SyncDisconnect():Promise; + +export function SyncNow():Promise>; + +export function SyncSetInterval(arg1:number):Promise; + +export function SyncStatus():Promise; + +export function SyncTestConnection(arg1:string,arg2:string,arg3:string):Promise; + export function TrashVaultPath(arg1:string,arg2:string):Promise; +export function TrashWorkspace(arg1:string):Promise; + export function UpdateAppSettings(arg1:Record):Promise; export function UpdateWorkbenchPreferences(arg1:workbench.Preferences):Promise; +export function UpdateWorkspaceMetadata(arg1:string,arg2:workspace.MetadataPatch):Promise; + export function WriteFrontendLog(arg1:string,arg2:string):Promise; export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index c73da96..4cf0d28 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -18,6 +18,10 @@ export function CreateVaultFolder(arg1, arg2) { return window['go']['api']['App']['CreateVaultFolder'](arg1, arg2); } +export function CreateWorkspace(arg1, arg2) { + return window['go']['api']['App']['CreateWorkspace'](arg1, arg2); +} + export function CreateWorkspaceNode(arg1, arg2, arg3) { return window['go']['api']['App']['CreateWorkspaceNode'](arg1, arg2, arg3); } @@ -50,6 +54,10 @@ export function GetContributions() { return window['go']['api']['App']['GetContributions'](); } +export function GetCurrentWorkspace() { + return window['go']['api']['App']['GetCurrentWorkspace'](); +} + export function GetCurrentWorkspaceNode() { return window['go']['api']['App']['GetCurrentWorkspaceNode'](); } @@ -94,6 +102,10 @@ export function GetWorkbenchPreferences() { return window['go']['api']['App']['GetWorkbenchPreferences'](); } +export function GetWorkspaceMetadata(arg1) { + return window['go']['api']['App']['GetWorkspaceMetadata'](arg1); +} + export function GetWorkspaceTree() { return window['go']['api']['App']['GetWorkspaceTree'](); } @@ -106,6 +118,10 @@ export function ListVaultFiles(arg1, arg2) { return window['go']['api']['App']['ListVaultFiles'](arg1, arg2); } +export function ListWorkspaces() { + return window['go']['api']['App']['ListWorkspaces'](); +} + export function MoveVaultPath(arg1, arg2, arg3, arg4) { return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4); } @@ -150,10 +166,18 @@ export function ReloadPlugins() { return window['go']['api']['App']['ReloadPlugins'](); } +export function RenameWorkspace(arg1, arg2) { + return window['go']['api']['App']['RenameWorkspace'](arg1, arg2); +} + export function RenameWorkspaceNode(arg1, arg2) { return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2); } +export function ResetSyncKey() { + return window['go']['api']['App']['ResetSyncKey'](); +} + export function SelectDirectory() { return window['go']['api']['App']['SelectDirectory'](); } @@ -166,6 +190,10 @@ export function SetCurrentVault(arg1) { return window['go']['api']['App']['SetCurrentVault'](arg1); } +export function SetCurrentWorkspace(arg1) { + return window['go']['api']['App']['SetCurrentWorkspace'](arg1); +} + export function SetCurrentWorkspaceNode(arg1) { return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1); } @@ -174,10 +202,38 @@ export function SubscribePluginEvent(arg1, arg2) { return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2); } +export function SyncConfigure(arg1, arg2, arg3) { + return window['go']['api']['App']['SyncConfigure'](arg1, arg2, arg3); +} + +export function SyncDisconnect() { + return window['go']['api']['App']['SyncDisconnect'](); +} + +export function SyncNow() { + return window['go']['api']['App']['SyncNow'](); +} + +export function SyncSetInterval(arg1) { + return window['go']['api']['App']['SyncSetInterval'](arg1); +} + +export function SyncStatus() { + return window['go']['api']['App']['SyncStatus'](); +} + +export function SyncTestConnection(arg1, arg2, arg3) { + return window['go']['api']['App']['SyncTestConnection'](arg1, arg2, arg3); +} + export function TrashVaultPath(arg1, arg2) { return window['go']['api']['App']['TrashVaultPath'](arg1, arg2); } +export function TrashWorkspace(arg1) { + return window['go']['api']['App']['TrashWorkspace'](arg1); +} + export function UpdateAppSettings(arg1) { return window['go']['api']['App']['UpdateAppSettings'](arg1); } @@ -186,6 +242,10 @@ export function UpdateWorkbenchPreferences(arg1) { return window['go']['api']['App']['UpdateWorkbenchPreferences'](arg1); } +export function UpdateWorkspaceMetadata(arg1, arg2) { + return window['go']['api']['App']['UpdateWorkspaceMetadata'](arg1, arg2); +} + export function WriteFrontendLog(arg1, arg2) { return window['go']['api']['App']['WriteFrontendLog'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 3894ca9..b8a18ac 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -206,6 +206,41 @@ export namespace api { + + export class SyncStatusDTO { + configured: boolean; + serverUrl: string; + deviceId: string; + deviceName: string; + connected: boolean; + revoked: boolean; + tokenStored: boolean; + unpushedOps: number; + lastSyncAt: string; + syncInterval: number; + lastError: string; + statusLabel: string; + + static createFrom(source: any = {}) { + return new SyncStatusDTO(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.configured = source["configured"]; + this.serverUrl = source["serverUrl"]; + this.deviceId = source["deviceId"]; + this.deviceName = source["deviceName"]; + this.connected = source["connected"]; + this.revoked = source["revoked"]; + this.tokenStored = source["tokenStored"]; + this.unpushedOps = source["unpushedOps"]; + this.lastSyncAt = source["lastSyncAt"]; + this.syncInterval = source["syncInterval"]; + this.lastError = source["lastError"]; + this.statusLabel = source["statusLabel"]; + } + } } @@ -1017,3 +1052,111 @@ export namespace workbench { } +export namespace workspace { + + export class TemplateSnapshot { + templateId: string; + templateName: string; + templateVersion: number; + appliedAt: string; + + static createFrom(source: any = {}) { + return new TemplateSnapshot(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.templateId = source["templateId"]; + this.templateName = source["templateName"]; + this.templateVersion = source["templateVersion"]; + this.appliedAt = source["appliedAt"]; + } + } + export class Metadata { + workspaceName: string; + createdFromTemplate?: TemplateSnapshot; + features?: Record; + folders?: Record; + updatedAt?: string; + + static createFrom(source: any = {}) { + return new Metadata(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.workspaceName = source["workspaceName"]; + this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot); + this.features = source["features"]; + this.folders = source["folders"]; + this.updatedAt = source["updatedAt"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class MetadataPatch { + features?: Record; + folders?: Record; + + static createFrom(source: any = {}) { + return new MetadataPatch(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.features = source["features"]; + this.folders = source["folders"]; + } + } + + export class TrashResult { + originalPath: string; + trashPath: string; + trashId: string; + deletedAt: string; + + static createFrom(source: any = {}) { + return new TrashResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.originalPath = source["originalPath"]; + this.trashPath = source["trashPath"]; + this.trashId = source["trashId"]; + this.deletedAt = source["deletedAt"]; + } + } + export class Workspace { + name: string; + rootPath: string; + + static createFrom(source: any = {}) { + return new Workspace(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.rootPath = source["rootPath"]; + } + } + +} + diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 9fe8c13..c7e24fa 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -479,11 +479,81 @@ func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) { if len(nodes) == 0 { t.Fatal("workspace nodes should not be empty") } + if nodes[0].Path != "" { + t.Fatalf("compatibility node should not expose workspace path mapping: %+v", nodes[0]) + } if !app.capRegistry.Has("verstak/core/workspace/v1") { t.Fatal("workspace capability should be registered after SetCurrentVault") } } +func TestWorkspaceAPIUsesTopLevelFoldersAndMetadataSnapshot(t *testing.T) { + app, vaultDir := newFilesTestApp(t, []string{"files.read"}) + app.workspace = workspace.NewManager(vaultDir) + if err := app.workspace.Load(); err != nil { + t.Fatalf("workspace Load: %v", err) + } + + ws, errStr := app.CreateWorkspace("Project", "client-project") + if errStr != "" { + t.Fatalf("CreateWorkspace: %s", errStr) + } + if ws.RootPath != "Project" { + t.Fatalf("workspace = %+v, want rootPath Project", ws) + } + if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil { + t.Fatalf("template file missing: %v", err) + } + + meta, errStr := app.GetWorkspaceMetadata("Project") + if errStr != "" { + t.Fatalf("GetWorkspaceMetadata: %s", errStr) + } + if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" { + t.Fatalf("metadata snapshot = %+v", meta.CreatedFromTemplate) + } + + if errStr := app.RenameWorkspace("Project", "Renamed"); errStr != "" { + t.Fatalf("RenameWorkspace: %s", errStr) + } + if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); err != nil { + t.Fatalf("renamed folder missing: %v", err) + } + + result, errStr := app.TrashWorkspace("Renamed") + if errStr != "" { + t.Fatalf("TrashWorkspace: %s", errStr) + } + if result.TrashPath == "" { + t.Fatalf("trash result = %+v", result) + } + if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); !os.IsNotExist(err) { + t.Fatalf("workspace should be moved out of top level, stat err=%v", err) + } +} + +func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) { + app, vaultDir := newFilesTestApp(t, []string{"files.read"}) + app.workspace = workspace.NewManager(vaultDir) + if err := app.workspace.Load(); err != nil { + t.Fatalf("workspace Load: %v", err) + } + if _, errStr := app.CreateWorkspace("Project", "default"); errStr != "" { + t.Fatalf("CreateWorkspace Project: %s", errStr) + } + if _, errStr := app.CreateWorkspace("Test", "default"); errStr != "" { + t.Fatalf("CreateWorkspace Test: %s", errStr) + } + + errStr := app.MoveWorkspaceNode("Project", "Test") + if errStr == "" || !strings.Contains(errStr, "top-level only") { + t.Fatalf("MoveWorkspaceNode error = %q, want top-level only", errStr) + } + if _, err := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(err) { + t.Fatalf("MoveWorkspaceNode created nested mapped workspace, stat err=%v", err) + } +} + func newBridgeTestApp(t *testing.T) *App { t.Helper() tmpDir := t.TempDir() diff --git a/internal/core/vault/vault.go b/internal/core/vault/vault.go index 87ea700..08f48bd 100644 --- a/internal/core/vault/vault.go +++ b/internal/core/vault/vault.go @@ -115,9 +115,10 @@ func (v *Vault) CreateVault(path string) error { return fmt.Errorf("failed to write vault.json: %w", err) } - // Create workspace.json with root node + // Create the initial physical workspace folder. Workspace listing is still + // sourced from top-level vault folders, not from .verstak metadata. wsMgr := workspace.NewManager(vaultDir) - if err := wsMgr.Load(); err != nil { + if _, err := wsMgr.CreateWorkspace("Workspace", "default"); err != nil { return fmt.Errorf("failed to create workspace: %w", err) } diff --git a/internal/core/vault/vault_test.go b/internal/core/vault/vault_test.go index 32200d8..687ed01 100644 --- a/internal/core/vault/vault_test.go +++ b/internal/core/vault/vault_test.go @@ -259,44 +259,14 @@ func TestCreateVault_CreatesWorkspace(t *testing.T) { t.Fatalf("CreateVault: %v", err) } - wsPath := filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json") - data, err := os.ReadFile(wsPath) - if err != nil { - t.Fatalf("workspace.json not found: %v", err) + if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil { + t.Fatalf("Workspace folder not found: %v", err) } - - var ws struct { - SchemaVersion int `json:"schemaVersion"` - Nodes []struct { - ID string `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - Status string `json:"status"` - ParentID string `json:"parentId"` - } `json:"nodes"` - CurrentNodeID string `json:"currentNodeId"` + if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes", "Overview.md")); err != nil { + t.Fatalf("default workspace overview not found: %v", err) } - if err := json.Unmarshal(data, &ws); err != nil { - t.Fatalf("failed to parse workspace.json: %v", err) - } - - if ws.SchemaVersion != 1 { - t.Errorf("schemaVersion: got %d, want 1", ws.SchemaVersion) - } - if len(ws.Nodes) != 1 { - t.Fatalf("expected 1 root node, got %d", len(ws.Nodes)) - } - if ws.Nodes[0].Type != "space" { - t.Errorf("root type: got %q, want %q", ws.Nodes[0].Type, "space") - } - if ws.Nodes[0].Title != "My Workspace" { - t.Errorf("root title: got %q, want %q", ws.Nodes[0].Title, "My Workspace") - } - if ws.Nodes[0].Status != "active" { - t.Errorf("root status: got %q, want %q", ws.Nodes[0].Status, "active") - } - if ws.CurrentNodeID != ws.Nodes[0].ID { - t.Errorf("currentNodeId should be root node id") + if _, err := os.Stat(filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")); !os.IsNotExist(err) { + t.Fatalf("workspace.json should not be created as workspace source of truth, stat err=%v", err) } } @@ -317,27 +287,11 @@ func TestOpenVault_WorkspaceLoads(t *testing.T) { t.Fatalf("OpenVault: %v", err) } - wsPath := filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json") - data, err := os.ReadFile(wsPath) - if err != nil { - t.Fatalf("workspace.json not found after reopen: %v", err) + if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil { + t.Fatalf("Workspace folder should still exist after reopen: %v", err) } - - var ws struct { - Nodes []struct { - ID string `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - } `json:"nodes"` - } - if err := json.Unmarshal(data, &ws); err != nil { - t.Fatalf("failed to parse workspace.json: %v", err) - } - if len(ws.Nodes) != 1 { - t.Fatalf("expected 1 node after reopen, got %d", len(ws.Nodes)) - } - if ws.Nodes[0].Type != "space" { - t.Errorf("root type after reopen: got %q, want %q", ws.Nodes[0].Type, "space") + if _, err := os.Stat(filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")); !os.IsNotExist(err) { + t.Fatalf("OpenVault should not create workspace.json, stat err=%v", err) } } diff --git a/internal/core/workspace/manager.go b/internal/core/workspace/manager.go index cfc0d21..0865101 100644 --- a/internal/core/workspace/manager.go +++ b/internal/core/workspace/manager.go @@ -1,16 +1,15 @@ -// Package workspace provides the core workspace/cases service for Verstak. -// It manages a tree of workspaces, cases, and folders inside a vault. +// Package workspace provides the semantic workspace lifecycle service. // -// This is NOT notes/files/editor — it is the foundational layer that -// organizes work into a hierarchy. Plugins later reference workspace -// nodes via stable IDs. +// A workspace is a top-level physical folder directly under the vault root. +// The filesystem is the source of truth for workspace existence and listing. +// Metadata under .verstak stores UI state and creation snapshots only. package workspace import ( + "encoding/base64" "encoding/json" "fmt" "os" - "path" "path/filepath" "sort" "strings" @@ -21,7 +20,7 @@ import ( "github.com/google/uuid" ) -// NodeType represents the type of a workspace node. +// NodeType is retained for compatibility with the current shell API. type NodeType string const ( @@ -30,7 +29,7 @@ const ( TypeFolder NodeType = "folder" ) -// NodeStatus represents the lifecycle status of a node. +// NodeStatus is retained for compatibility with the current shell API. type NodeStatus string const ( @@ -39,21 +38,62 @@ const ( StatusArchived NodeStatus = "archived" ) -// WorkspaceNode is a single item in the workspace tree. +// Workspace is a physical top-level workspace folder. +type Workspace struct { + Name string `json:"name"` + RootPath string `json:"rootPath"` +} + +// TemplateSnapshot is copied into workspace metadata when a template is applied. +type TemplateSnapshot struct { + TemplateID string `json:"templateId"` + TemplateName string `json:"templateName"` + TemplateVersion int `json:"templateVersion"` + AppliedAt string `json:"appliedAt"` +} + +// Metadata stores semantic workspace metadata that is not the source of truth +// for whether the workspace exists. +type Metadata struct { + WorkspaceName string `json:"workspaceName"` + CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"` + Features map[string]bool `json:"features,omitempty"` + Folders map[string]string `json:"folders,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// MetadataPatch updates metadata fields without replacing unspecified fields. +type MetadataPatch struct { + Features map[string]bool `json:"features,omitempty"` + Folders map[string]string `json:"folders,omitempty"` +} + +// TrashResult describes a workspace moved into the internal trash area. +type TrashResult struct { + OriginalPath string `json:"originalPath"` + TrashPath string `json:"trashPath"` + TrashID string `json:"trashId"` + DeletedAt string `json:"deletedAt"` +} + +// WorkspaceNode is a compatibility shell view of a top-level workspace. +// Path is deliberately not serialized; workspaceRootPath is derived from Name/ID. type WorkspaceNode struct { ID string `json:"id"` ParentID string `json:"parentId,omitempty"` Type NodeType `json:"type"` Title string `json:"title"` - Path string `json:"path,omitempty"` + Name string `json:"name,omitempty"` + RootPath string `json:"rootPath,omitempty"` + Path string `json:"-"` Status NodeStatus `json:"status"` Tags []string `json:"tags,omitempty"` Order int `json:"order"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` } -// WorkspaceTree holds the full node tree and current selection. +// WorkspaceTree is a compatibility flat list, derived from top-level folders. type WorkspaceTree struct { SchemaVersion int `json:"schemaVersion"` Nodes []WorkspaceNode `json:"nodes"` @@ -61,600 +101,694 @@ type WorkspaceTree struct { UpdatedAt string `json:"updatedAt"` } -// Manager provides workspace operations. +type templateDefinition struct { + ID string + Name string + Version int + Features map[string]bool + Folders map[string]string + Files map[string]string +} + +var builtInTemplates = map[string]templateDefinition{ + "default": { + ID: "default", + Name: "Default Workspace", + Version: 1, + Features: map[string]bool{ + "files": true, + "notes": true, + "secrets": false, + "activity": false, + }, + Folders: map[string]string{ + "notes": "Notes", + "files": "Files", + }, + Files: map[string]string{ + "Notes/Overview.md": "# Overview\n", + }, + }, + "client-project": { + ID: "client-project", + Name: "Client Project", + Version: 1, + Features: map[string]bool{ + "files": true, + "notes": true, + "secrets": true, + "activity": false, + }, + Folders: map[string]string{ + "notes": "Notes", + "files": "Files", + "secrets": "Secrets", + }, + Files: map[string]string{ + "Notes/Overview.md": "# Overview\n", + }, + }, +} + +// Manager provides workspace operations for one vault. type Manager struct { - mu sync.RWMutex - tree *WorkspaceTree - vaultDir string + mu sync.RWMutex + vaultDir string + initialized bool + currentWorkspaceName string } // NewManager creates a workspace manager for the given vault directory. func NewManager(vaultDir string) *Manager { - return &Manager{ - vaultDir: vaultDir, - } + return &Manager{vaultDir: vaultDir} } -// workspaceFilePath returns the path to workspace.json inside the vault. -func (m *Manager) workspaceFilePath() string { - return filepath.Join(m.vaultDir, ".verstak", "workspace.json") -} - -// Load reads the workspace tree from disk. -// If no file exists, creates a default tree with a root node. +// Load initializes the manager without creating or migrating workspace folders. func (m *Manager) Load() error { m.mu.Lock() defer m.mu.Unlock() + m.initialized = true + m.currentWorkspaceName = m.readSelectedWorkspaceLocked() + return nil +} - path := m.workspaceFilePath() - data, err := os.ReadFile(path) +// IsInitialized returns true after Load has been called. +func (m *Manager) IsInitialized() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.initialized +} + +// ListWorkspaces returns top-level physical workspace folders from the vault. +func (m *Manager) ListWorkspaces() ([]Workspace, error) { + entries, err := os.ReadDir(m.vaultDir) + if err != nil { + return nil, err + } + + workspaces := make([]Workspace, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if isReservedWorkspaceName(name) { + continue + } + if entry.Type()&os.ModeSymlink != 0 { + continue + } + if !entry.IsDir() { + continue + } + workspaces = append(workspaces, Workspace{Name: name, RootPath: name}) + } + sort.Slice(workspaces, func(i, j int) bool { + return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name) + }) + return workspaces, nil +} + +// CreateWorkspace creates a top-level workspace folder and applies a template once. +func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) { + name = strings.TrimSpace(name) + if err := validateWorkspaceName(name); err != nil { + return Workspace{}, err + } + if templateID == "" { + templateID = "default" + } + template, ok := builtInTemplates[templateID] + if !ok { + return Workspace{}, fmt.Errorf("template-not-found: %s", templateID) + } + + full := filepath.Join(m.vaultDir, name) + if _, err := os.Lstat(full); err == nil { + return Workspace{}, fmt.Errorf("conflict: %s", name) + } else if !os.IsNotExist(err) { + return Workspace{}, err + } + if err := os.Mkdir(full, 0o755); err != nil { + return Workspace{}, err + } + created := true + defer func() { + if created { + _ = os.RemoveAll(full) + } + }() + + if err := applyTemplate(full, template); err != nil { + return Workspace{}, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + meta := Metadata{ + WorkspaceName: name, + CreatedFromTemplate: &TemplateSnapshot{ + TemplateID: template.ID, + TemplateName: template.Name, + TemplateVersion: template.Version, + AppliedAt: now, + }, + Features: cloneBoolMap(template.Features), + Folders: cloneStringMap(template.Folders), + UpdatedAt: now, + } + if err := m.writeMetadata(name, meta); err != nil { + return Workspace{}, err + } + + created = false + return Workspace{Name: name, RootPath: name}, nil +} + +// RenameWorkspace physically renames a top-level workspace folder and metadata key. +func (m *Manager) RenameWorkspace(oldName, newName string) error { + oldName = strings.TrimSpace(oldName) + newName = strings.TrimSpace(newName) + if err := validateWorkspaceName(oldName); err != nil { + return err + } + if err := validateWorkspaceName(newName); err != nil { + return err + } + oldFull := filepath.Join(m.vaultDir, oldName) + newFull := filepath.Join(m.vaultDir, newName) + if err := ensureExistingWorkspaceDir(oldFull, oldName); err != nil { + return err + } + if _, err := os.Lstat(newFull); err == nil { + return fmt.Errorf("conflict: %s", newName) + } else if !os.IsNotExist(err) { + return err + } + + if err := os.Rename(oldFull, newFull); err != nil { + return err + } + renamedFolder := true + defer func() { + if renamedFolder { + _ = os.Rename(newFull, oldFull) + } + }() + + oldMetaPath := m.metadataPath(oldName) + if data, err := os.ReadFile(oldMetaPath); err == nil { + var meta Metadata + if err := json.Unmarshal(data, &meta); err != nil { + return err + } + meta.WorkspaceName = newName + meta.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + if err := m.writeMetadata(newName, meta); err != nil { + return err + } + if err := os.Remove(oldMetaPath); err != nil && !os.IsNotExist(err) { + return err + } + } else if !os.IsNotExist(err) { + return err + } + + m.mu.Lock() + if m.currentWorkspaceName == oldName { + m.currentWorkspaceName = newName + _ = m.writeUIStateLocked() + } + m.mu.Unlock() + + renamedFolder = false + return nil +} + +// TrashWorkspace moves the whole top-level workspace folder to internal trash. +func (m *Manager) TrashWorkspace(name string) (TrashResult, error) { + name = strings.TrimSpace(name) + if err := validateWorkspaceName(name); err != nil { + return TrashResult{}, err + } + full := filepath.Join(m.vaultDir, name) + if err := ensureExistingWorkspaceDir(full, name); err != nil { + return TrashResult{}, err + } + + deletedAt := time.Now().UTC().Format(time.RFC3339Nano) + trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString() + trashRel := filepath.ToSlash(filepath.Join(".verstak", "trash", "workspaces", trashID, name)) + trashFull := filepath.Join(m.vaultDir, filepath.FromSlash(trashRel)) + if err := os.MkdirAll(filepath.Dir(trashFull), 0o755); err != nil { + return TrashResult{}, err + } + if err := os.Rename(full, trashFull); err != nil { + return TrashResult{}, err + } + + result := TrashResult{OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt} + trashMeta := map[string]string{ + "originalPath": name, + "trashPath": trashRel, + "trashId": trashID, + "deletedAt": deletedAt, + "originalType": "folder", + "basename": name, + "type": "workspace", + } + data, err := json.MarshalIndent(trashMeta, "", " ") + if err != nil { + return TrashResult{}, err + } + trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID) + if err := os.WriteFile(filepath.Join(trashDir, "metadata.json"), data, 0o644); err != nil { + return TrashResult{}, err + } + if err := moveIfExists(m.metadataPath(name), filepath.Join(trashDir, "workspace.metadata.json")); err != nil { + return TrashResult{}, err + } + + m.mu.Lock() + if m.currentWorkspaceName == name { + m.currentWorkspaceName = "" + _ = m.writeUIStateLocked() + } + m.mu.Unlock() + + return result, nil +} + +// GetWorkspaceMetadata returns stored metadata or safe generic metadata. +func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) { + name = strings.TrimSpace(name) + if err := validateWorkspaceName(name); err != nil { + return Metadata{}, err + } + full := filepath.Join(m.vaultDir, name) + if err := ensureExistingWorkspaceDir(full, name); err != nil { + return Metadata{}, err + } + + data, err := os.ReadFile(m.metadataPath(name)) if err != nil { if os.IsNotExist(err) { - m.tree = m.defaultTree() - if _, err := m.ensureWorkspacePathsLocked(); err != nil { - return err - } - return m.saveLocked() + return genericMetadata(name), nil } - return fmt.Errorf("failed to read workspace.json: %w", err) + return Metadata{}, err + } + var meta Metadata + if err := json.Unmarshal(data, &meta); err != nil { + return Metadata{}, err + } + // Workspace identity is the top-level folder name. Stored metadata may be + // stale after manual edits or old dev snapshots, so normalize the returned + // presentation name without writing back to disk. + meta.WorkspaceName = name + if meta.Features == nil { + meta.Features = map[string]bool{"files": true} + } + if !hasAnyTrueFeature(meta.Features) { + meta.Features["files"] = true + } + if meta.Folders == nil { + meta.Folders = defaultFolders() + } + return meta, nil +} + +// UpdateWorkspaceMetadata merges UI/semantic metadata fields for an existing workspace. +func (m *Manager) UpdateWorkspaceMetadata(name string, patch MetadataPatch) (Metadata, error) { + meta, err := m.GetWorkspaceMetadata(name) + if err != nil { + return Metadata{}, err + } + if meta.Features == nil { + meta.Features = map[string]bool{} + } + for k, v := range patch.Features { + meta.Features[k] = v + } + if meta.Folders == nil { + meta.Folders = map[string]string{} + } + for k, v := range patch.Folders { + meta.Folders[k] = v + } + meta.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + if err := m.writeMetadata(name, meta); err != nil { + return Metadata{}, err + } + return meta, nil +} + +// GetTree returns a compatibility flat tree derived from top-level folders. +func (m *Manager) GetTree() WorkspaceTree { + workspaces, err := m.ListWorkspaces() + if err != nil { + return WorkspaceTree{SchemaVersion: 1, Nodes: []WorkspaceNode{}} } - var tree WorkspaceTree - if err := json.Unmarshal(data, &tree); err != nil { - // Corrupt: backup and create defaults - backupPath := path + ".corrupt." + time.Now().Format("20060102-150405") - os.WriteFile(backupPath, data, 0o600) - m.tree = m.defaultTree() - if saveErr := m.saveLocked(); saveErr != nil { - return fmt.Errorf("corrupt workspace.json (backed up to %s), failed to save defaults: %w", backupPath, saveErr) + m.mu.RLock() + current := m.currentWorkspaceName + m.mu.RUnlock() + if current == "" || !workspaceExists(workspaces, current) { + if len(workspaces) > 0 { + current = workspaces[0].Name } - return fmt.Errorf("corrupt workspace.json (backed up to %s), defaults created", backupPath) } - if tree.SchemaVersion != 1 { - tree.SchemaVersion = 1 - } - if tree.Nodes == nil { - tree.Nodes = []WorkspaceNode{} + nodes := make([]WorkspaceNode, 0, len(workspaces)) + now := time.Now().UTC().Format(time.RFC3339Nano) + for i, ws := range workspaces { + nodes = append(nodes, WorkspaceNode{ + ID: ws.Name, + Type: TypeSpace, + Title: ws.Name, + Name: ws.Name, + RootPath: ws.RootPath, + Status: StatusActive, + Order: i, + CreatedAt: now, + UpdatedAt: now, + }) } + return WorkspaceTree{SchemaVersion: 1, Nodes: nodes, CurrentNodeID: current, UpdatedAt: now} +} - m.tree = &tree - changed, err := m.ensureWorkspacePathsLocked() +// GetNode returns a compatibility node by workspace name. +func (m *Manager) GetNode(id string) (WorkspaceNode, error) { + for _, node := range m.GetTree().Nodes { + if node.ID == id { + return node, nil + } + } + return WorkspaceNode{}, fmt.Errorf("workspace not found: %s", id) +} + +// ListChildren returns no children because workspaces are only top-level folders. +func (m *Manager) ListChildren(parentID string) []WorkspaceNode { + if parentID != "" { + return nil + } + return m.GetTree().Nodes +} + +// CreateNode is a compatibility wrapper for creating top-level workspaces only. +func (m *Manager) CreateNode(parentID string, nodeType NodeType, title string) (WorkspaceNode, error) { + if parentID != "" { + return WorkspaceNode{}, fmt.Errorf("workspace folders are top-level only") + } + if nodeType != "" && nodeType != TypeSpace { + return WorkspaceNode{}, fmt.Errorf("workspace folders are top-level only") + } + ws, err := m.CreateWorkspace(title, "") + if err != nil { + return WorkspaceNode{}, err + } + return WorkspaceNode{ID: ws.Name, Type: TypeSpace, Title: ws.Name, Name: ws.Name, RootPath: ws.RootPath, Status: StatusActive}, nil +} + +// RenameNode is a compatibility wrapper for physical workspace rename. +func (m *Manager) RenameNode(id, title string) error { + return m.RenameWorkspace(id, title) +} + +// MoveNode is unsupported in the corrected workspace model. +func (m *Manager) MoveNode(id, newParentID string) error { + return fmt.Errorf("workspace folders are top-level only") +} + +// ArchiveNode is a compatibility wrapper for trashing a workspace. +func (m *Manager) ArchiveNode(id string) error { + _, err := m.TrashWorkspace(id) + return err +} + +// SetCurrentNode stores UI selection only. +func (m *Manager) SetCurrentNode(id string) error { + if err := validateWorkspaceName(id); err != nil { + return err + } + workspaces, err := m.ListWorkspaces() if err != nil { return err } - if changed { - return m.saveLocked() + if !workspaceExists(workspaces, id) { + return fmt.Errorf("workspace not found: %s", id) } - return nil + + m.mu.Lock() + defer m.mu.Unlock() + m.currentWorkspaceName = id + return m.writeUIStateLocked() } -// saveLocked writes the workspace tree to disk atomically. -// Must be called with m.mu held (write lock). -func (m *Manager) saveLocked() error { - if m.tree == nil { - return fmt.Errorf("workspace tree is nil") +// GetCurrentNode returns the currently selected compatibility node. +func (m *Manager) GetCurrentNode() (WorkspaceNode, error) { + tree := m.GetTree() + if tree.CurrentNodeID == "" { + return WorkspaceNode{}, fmt.Errorf("no current workspace") } - - m.tree.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) - - data, err := json.MarshalIndent(m.tree, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal workspace tree: %w", err) + for _, node := range tree.Nodes { + if node.ID == tree.CurrentNodeID { + return node, nil + } } - - path := m.workspaceFilePath() - tmpPath := path + ".tmp" - - if err := os.WriteFile(tmpPath, data, 0o600); err != nil { - return fmt.Errorf("failed to write workspace.json.tmp: %w", err) - } - - if err := os.Rename(tmpPath, path); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("failed to rename workspace.json: %w", err) - } - - return nil + return WorkspaceNode{}, fmt.Errorf("current workspace not found: %s", tree.CurrentNodeID) } -// Save persists the current tree to disk. +// Save persists UI state only; workspace existence is never persisted here. func (m *Manager) Save() error { m.mu.Lock() defer m.mu.Unlock() - return m.saveLocked() + return m.writeUIStateLocked() } -// defaultTree creates a fresh workspace tree with a single root node. -func (m *Manager) defaultTree() *WorkspaceTree { - now := time.Now().UTC().Format(time.RFC3339Nano) - root := WorkspaceNode{ - ID: uuid.New().String(), - Type: TypeSpace, - Title: "My Workspace", - Path: safePathSegment("My Workspace"), - Status: StatusActive, - Order: 0, - CreatedAt: now, - UpdatedAt: now, - } - return &WorkspaceTree{ - SchemaVersion: 1, - Nodes: []WorkspaceNode{root}, - CurrentNodeID: root.ID, - UpdatedAt: now, - } -} - -// GetTree returns a copy of the full tree. -func (m *Manager) GetTree() WorkspaceTree { - m.mu.RLock() - defer m.mu.RUnlock() - if m.tree == nil { - return WorkspaceTree{SchemaVersion: 1} - } - return *m.tree -} - -// GetNode returns a node by ID. -func (m *Manager) GetNode(id string) (WorkspaceNode, error) { - m.mu.RLock() - defer m.mu.RUnlock() - if m.tree == nil { - return WorkspaceNode{}, fmt.Errorf("workspace not initialized") - } - for _, n := range m.tree.Nodes { - if n.ID == id { - return n, nil +func applyTemplate(workspaceDir string, template templateDefinition) error { + for rel, content := range template.Files { + if strings.Contains(rel, "\x00") || strings.Contains(rel, "\\") || strings.HasPrefix(rel, "/") { + return fmt.Errorf("invalid-template-path: %s", rel) } - } - return WorkspaceNode{}, fmt.Errorf("node not found: %s", id) -} - -// ListChildren returns direct children of a parent node, sorted by order. -func (m *Manager) ListChildren(parentID string) []WorkspaceNode { - m.mu.RLock() - defer m.mu.RUnlock() - if m.tree == nil { - return nil - } - var children []WorkspaceNode - for _, n := range m.tree.Nodes { - if n.ParentID == parentID { - children = append(children, n) - } - } - sort.Slice(children, func(i, j int) bool { - return children[i].Order < children[j].Order - }) - return children -} - -// CreateNode creates a new node under the given parent. -func (m *Manager) CreateNode(parentID string, nodeType NodeType, title string) (WorkspaceNode, error) { - if nodeType != TypeSpace && nodeType != TypeCase && nodeType != TypeFolder { - return WorkspaceNode{}, fmt.Errorf("invalid node type: %s", nodeType) - } - if strings.TrimSpace(title) == "" { - return WorkspaceNode{}, fmt.Errorf("title cannot be empty") - } - - m.mu.Lock() - defer m.mu.Unlock() - - if m.tree == nil { - return WorkspaceNode{}, fmt.Errorf("workspace not initialized") - } - - // Validate parent exists (empty parentID means root-level) - if parentID != "" { - parentFound := false - for _, n := range m.tree.Nodes { - if n.ID == parentID { - parentFound = true - break + parts := strings.Split(rel, "/") + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return fmt.Errorf("invalid-template-path: %s", rel) } } - if !parentFound { - return WorkspaceNode{}, fmt.Errorf("parent node not found: %s", parentID) + full := filepath.Join(workspaceDir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return err } - } - - now := time.Now().UTC().Format(time.RFC3339Nano) - - // Calculate order: max existing sibling order + 1 - maxOrder := -1 - for _, n := range m.tree.Nodes { - if n.ParentID == parentID && n.Order > maxOrder { - maxOrder = n.Order - } - } - - node := WorkspaceNode{ - ID: uuid.New().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) == "" { - return fmt.Errorf("title cannot be empty") - } - - m.mu.Lock() - defer m.mu.Unlock() - - if m.tree == nil { - return fmt.Errorf("workspace not initialized") - } - - for i := range m.tree.Nodes { - if m.tree.Nodes[i].ID == id { - m.tree.Nodes[i].Title = title - m.tree.Nodes[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) - return m.saveLocked() - } - } - return fmt.Errorf("node not found: %s", id) -} - -// MoveNode changes a node's parent and order. -func (m *Manager) MoveNode(id, newParentID string) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.tree == nil { - return fmt.Errorf("workspace not initialized") - } - - // Find the node - nodeIdx := -1 - for i := range m.tree.Nodes { - if m.tree.Nodes[i].ID == id { - nodeIdx = i - break - } - } - if nodeIdx < 0 { - return fmt.Errorf("node not found: %s", id) - } - - // Cannot move to self - if newParentID == id { - return fmt.Errorf("cannot move node into itself") - } - - // Cannot move to own descendant - if m.isDescendant(id, newParentID) { - return fmt.Errorf("cannot move node into its own descendant") - } - - // Validate new parent exists (empty = root level) - if newParentID != "" { - parentFound := false - for _, n := range m.tree.Nodes { - if n.ID == newParentID { - parentFound = true - break - } - } - if !parentFound { - return fmt.Errorf("parent node not found: %s", newParentID) - } - } - - // Calculate new order - maxOrder := -1 - for _, n := range m.tree.Nodes { - if n.ParentID == newParentID && n.Order > maxOrder { - maxOrder = n.Order - } - } - - 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 { + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { 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) - - 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))) + for _, folder := range template.Folders { + if folder == "" { + continue + } + if err := os.MkdirAll(filepath.Join(workspaceDir, folder), 0o755); err != nil { + return err } - return err } return nil } -// isDescendant checks if targetID is a descendant of ancestorID. -func (m *Manager) isDescendant(ancestorID, targetID string) bool { - if targetID == "" { - return false +func validateWorkspaceName(name string) error { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("invalid-workspace-name: empty") } - // Build parent map - parentMap := make(map[string]string) - for _, n := range m.tree.Nodes { - parentMap[n.ID] = n.ParentID + if strings.Contains(name, "\x00") { + return fmt.Errorf("invalid-workspace-name: null-byte") } - // Walk up from target - current := targetID - for current != "" { - if current == ancestorID { + if strings.ContainsAny(name, `/\`) { + return fmt.Errorf("invalid-workspace-name: path separators are not allowed") + } + if looksAbsoluteName(name) { + return fmt.Errorf("invalid-workspace-name: absolute path rejected") + } + if name == "." || name == ".." || strings.Contains(name, "..") { + return fmt.Errorf("invalid-workspace-name: path traversal") + } + for _, r := range name { + if unicode.IsControl(r) { + return fmt.Errorf("invalid-workspace-name: control character") + } + } + if isReservedWorkspaceName(name) { + return fmt.Errorf("reserved-workspace-name: %s", name) + } + return nil +} + +func looksAbsoluteName(name string) bool { + if filepath.IsAbs(name) || strings.HasPrefix(name, "/") || strings.HasPrefix(name, "\\") { + return true + } + return len(name) >= 2 && name[1] == ':' && unicode.IsLetter(rune(name[0])) +} + +func isReservedWorkspaceName(name string) bool { + reserved := []string{".verstak", ".git"} + for _, item := range reserved { + if strings.EqualFold(name, item) { return true } - current = parentMap[current] } 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 - } +func ensureExistingWorkspaceDir(full, name string) error { + info, err := os.Lstat(full) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("not-found: %s", name) } + return err } - return subtree + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink-not-allowed: %s", name) + } + if !info.IsDir() { + return fmt.Errorf("not-directory: %s", name) + } + return nil } -func (m *Manager) rewriteDescendantPathsLocked(rootID, oldRootPath, newRootPath string) { - if oldRootPath == "" || oldRootPath == newRootPath { - return +func (m *Manager) metadataPath(name string) string { + encoded := base64.RawURLEncoding.EncodeToString([]byte(name)) + return filepath.Join(m.vaultDir, ".verstak", "workspaces", encoded+".json") +} + +func (m *Manager) writeMetadata(name string, meta Metadata) error { + if err := os.MkdirAll(filepath.Join(m.vaultDir, ".verstak", "workspaces"), 0o755); err != nil { + return err } - 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 - } + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + path := m.metadataPath(name) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +func (m *Manager) uiStatePath() string { + return filepath.Join(m.vaultDir, ".verstak", "workspace-ui.json") +} + +func (m *Manager) readSelectedWorkspaceLocked() string { + data, err := os.ReadFile(m.uiStatePath()) + if err != nil { + return "" + } + var state struct { + SelectedWorkspace string `json:"selectedWorkspace"` + } + if err := json.Unmarshal(data, &state); err != nil { + return "" + } + return state.SelectedWorkspace +} + +func (m *Manager) writeUIStateLocked() error { + if err := os.MkdirAll(filepath.Join(m.vaultDir, ".verstak"), 0o755); err != nil { + return err + } + state := struct { + SelectedWorkspace string `json:"selectedWorkspace,omitempty"` + UpdatedAt string `json:"updatedAt"` + }{ + SelectedWorkspace: m.currentWorkspaceName, + UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano), + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + path := m.uiStatePath() + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +func genericMetadata(name string) Metadata { + return Metadata{ + WorkspaceName: name, + Features: map[string]bool{"files": true}, + Folders: defaultFolders(), } } -// ArchiveNode sets a node's status to archived. -func (m *Manager) ArchiveNode(id string) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.tree == nil { - return fmt.Errorf("workspace not initialized") +func defaultFolders() map[string]string { + return map[string]string{ + "notes": "Notes", + "files": "Files", } +} - for i := range m.tree.Nodes { - if m.tree.Nodes[i].ID == id { - m.tree.Nodes[i].Status = StatusArchived - m.tree.Nodes[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) - return m.saveLocked() +func cloneBoolMap(src map[string]bool) map[string]bool { + dst := make(map[string]bool, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func cloneStringMap(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func hasAnyTrueFeature(features map[string]bool) bool { + for _, enabled := range features { + if enabled { + return true } } - return fmt.Errorf("node not found: %s", id) + return false } -// SetCurrentNode sets the currently selected node. -func (m *Manager) SetCurrentNode(id string) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.tree == nil { - return fmt.Errorf("workspace not initialized") - } - - // Validate node exists - found := false - for _, n := range m.tree.Nodes { - if n.ID == id { - found = true - break +func workspaceExists(workspaces []Workspace, name string) bool { + for _, ws := range workspaces { + if ws.Name == name { + return true } } - if !found { - return fmt.Errorf("node not found: %s", id) - } - - m.tree.CurrentNodeID = id - m.tree.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) - return m.saveLocked() + return false } -// GetCurrentNode returns the currently selected node. -func (m *Manager) GetCurrentNode() (WorkspaceNode, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - if m.tree == nil || m.tree.CurrentNodeID == "" { - return WorkspaceNode{}, fmt.Errorf("no current node") - } - - for _, n := range m.tree.Nodes { - if n.ID == m.tree.CurrentNodeID { - return n, nil +func moveIfExists(from, to string) error { + if _, err := os.Stat(from); err != nil { + if os.IsNotExist(err) { + return nil } + return err } - return WorkspaceNode{}, fmt.Errorf("current node not found: %s", m.tree.CurrentNodeID) + if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { + return err + } + return os.Rename(from, to) } -// IsInitialized returns true if the workspace has been loaded. -func (m *Manager) IsInitialized() bool { - m.mu.RLock() - defer m.mu.RUnlock() - return m.tree != nil +// ClearTemplateRegistryForTest simulates templates disappearing after a workspace +// has already stored its creation snapshot. +func ClearTemplateRegistryForTest(t interface{ Cleanup(func()) }) { + original := builtInTemplates + builtInTemplates = map[string]templateDefinition{} + t.Cleanup(func() { + builtInTemplates = original + }) } diff --git a/internal/core/workspace/manager_test.go b/internal/core/workspace/manager_test.go index d1c0820..9047e4f 100644 --- a/internal/core/workspace/manager_test.go +++ b/internal/core/workspace/manager_test.go @@ -1,403 +1,375 @@ package workspace import ( + "encoding/json" "os" "path/filepath" + "runtime" + "strings" "testing" ) -func TestLoad_DefaultRootNode(t *testing.T) { - dir := t.TempDir() - vaultDir := filepath.Join(dir, "vault") - verstakDir := filepath.Join(vaultDir, ".verstak") - os.MkdirAll(verstakDir, 0o755) +func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) { + vaultDir := newVaultDir(t) + mustMkdir(t, filepath.Join(vaultDir, "Project")) + mustMkdir(t, filepath.Join(vaultDir, "Test")) + mustMkdir(t, filepath.Join(vaultDir, ".verstak")) + mustMkdir(t, filepath.Join(vaultDir, ".git")) + mustWrite(t, filepath.Join(vaultDir, "readme.md"), "not a workspace") m := NewManager(vaultDir) if err := m.Load(); err != nil { t.Fatalf("Load: %v", err) } - tree := m.GetTree() - if len(tree.Nodes) != 1 { - t.Fatalf("expected 1 root node, got %d", len(tree.Nodes)) + workspaces, err := m.ListWorkspaces() + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) } - if tree.Nodes[0].Type != TypeSpace { - t.Errorf("root type: got %s, want %s", tree.Nodes[0].Type, TypeSpace) - } - if tree.Nodes[0].Title != "My Workspace" { - t.Errorf("root title: got %q, want %q", tree.Nodes[0].Title, "My Workspace") - } - if tree.CurrentNodeID != tree.Nodes[0].ID { - t.Errorf("current node should be root") + + got := workspaceNames(workspaces) + want := []string{"Project", "Test"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("workspaces = %v, want %v", got, want) } } -func TestCreateNode_Case(t *testing.T) { - dir := t.TempDir() - vaultDir := filepath.Join(dir, "vault") - verstakDir := filepath.Join(vaultDir, ".verstak") - os.MkdirAll(verstakDir, 0o755) +func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs extra privileges on Windows") + } + vaultDir := newVaultDir(t) + target := filepath.Join(t.TempDir(), "outside") + mustMkdir(t, target) + if err := os.Symlink(target, filepath.Join(vaultDir, "Linked")); err != nil { + t.Fatalf("Symlink: %v", err) + } + + m := NewManager(vaultDir) + workspaces, err := m.ListWorkspaces() + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(workspaces) != 0 { + t.Fatalf("expected symlink workspace to be excluded, got %+v", workspaces) + } +} + +func TestLoadDoesNotCreateOrMigrateFoldersFromOldWorkspaceJSON(t *testing.T) { + vaultDir := newVaultDir(t) + mustMkdir(t, filepath.Join(vaultDir, ".verstak")) + oldTree := `{"schemaVersion":1,"nodes":[{"id":"old","type":"space","title":"Old Tree Workspace","path":"Old Tree Workspace"}],"currentNodeId":"old"}` + mustWrite(t, filepath.Join(vaultDir, ".verstak", "workspace.json"), oldTree) m := NewManager(vaultDir) if err := m.Load(); err != nil { t.Fatalf("Load: %v", err) } + if _, err := os.Stat(filepath.Join(vaultDir, "Old Tree Workspace")); !os.IsNotExist(err) { + t.Fatalf("Load created folder from old workspace.json, stat err=%v", err) + } - rootID := m.GetTree().Nodes[0].ID - - node, err := m.CreateNode(rootID, TypeCase, "Test Case") + workspaces, err := m.ListWorkspaces() if err != nil { - t.Fatalf("CreateNode: %v", err) + t.Fatalf("ListWorkspaces: %v", err) } - if node.Type != TypeCase { - t.Errorf("type: got %s, want %s", node.Type, TypeCase) + if len(workspaces) != 0 { + t.Fatalf("workspace.json tree should not be source of truth, got %+v", workspaces) } - if node.Title != "Test Case" { - t.Errorf("title: got %q, want %q", node.Title, "Test Case") +} + +func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + + ws, err := m.CreateWorkspace("Project", "") + if err != nil { + t.Fatalf("CreateWorkspace: %v", err) } - if node.ParentID != rootID { - t.Errorf("parentID: got %q, want %q", node.ParentID, rootID) + if ws.Name != "Project" || ws.RootPath != "Project" { + t.Fatalf("workspace = %+v, want Project root", ws) } - if node.Status != StatusActive { - t.Errorf("status: got %s, want %s", node.Status, StatusActive) + if _, err := os.Stat(filepath.Join(vaultDir, "Project")); err != nil { + t.Fatalf("workspace folder missing: %v", err) } - 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) + if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil { + t.Fatalf("default template overview missing: %v", err) } - // Verify persisted + meta, err := m.GetWorkspaceMetadata("Project") + if err != nil { + t.Fatalf("GetWorkspaceMetadata: %v", err) + } + if meta.WorkspaceName != "Project" { + t.Fatalf("metadata workspaceName = %q", meta.WorkspaceName) + } + if meta.CreatedFromTemplate == nil { + t.Fatal("metadata missing createdFromTemplate snapshot") + } + if meta.CreatedFromTemplate.TemplateID != "default" || meta.CreatedFromTemplate.TemplateName == "" || meta.CreatedFromTemplate.TemplateVersion == 0 || meta.CreatedFromTemplate.AppliedAt == "" { + t.Fatalf("bad template snapshot: %+v", meta.CreatedFromTemplate) + } + if !meta.Features["files"] || !meta.Features["notes"] { + t.Fatalf("features = %+v, want files and notes enabled", meta.Features) + } + if meta.Folders["notes"] != "Notes" { + t.Fatalf("folders = %+v, want notes folder", meta.Folders) + } +} + +func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + if _, err := m.CreateWorkspace("ClientA", "client-project"); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + ClearTemplateRegistryForTest(t) + + meta, err := m.GetWorkspaceMetadata("ClientA") + if err != nil { + t.Fatalf("GetWorkspaceMetadata after registry clear: %v", err) + } + if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" { + t.Fatalf("snapshot not preserved after registry clear: %+v", meta.CreatedFromTemplate) + } +} + +func TestMissingMetadataReturnsGenericWorkspaceMetadata(t *testing.T) { + vaultDir := newVaultDir(t) + mustMkdir(t, filepath.Join(vaultDir, "Loose")) + + m := NewManager(vaultDir) + meta, err := m.GetWorkspaceMetadata("Loose") + if err != nil { + t.Fatalf("GetWorkspaceMetadata: %v", err) + } + if meta.WorkspaceName != "Loose" { + t.Fatalf("workspaceName = %q", meta.WorkspaceName) + } + if meta.CreatedFromTemplate != nil { + t.Fatalf("generic metadata should not invent a template snapshot: %+v", meta.CreatedFromTemplate) + } + if !meta.Features["files"] { + t.Fatalf("generic metadata should enable files at minimum: %+v", meta.Features) + } +} + +func TestGetWorkspaceMetadataReturnsCanonicalFolderNameWhenStoredNameIsStale(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + if _, err := m.CreateWorkspace("Project", "default"); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + data, err := os.ReadFile(m.metadataPath("Project")) + if err != nil { + t.Fatalf("read metadata: %v", err) + } + var meta Metadata + if err := json.Unmarshal(data, &meta); err != nil { + t.Fatalf("unmarshal metadata: %v", err) + } + meta.WorkspaceName = "OldName" + staleData, err := json.MarshalIndent(meta, "", " ") + if err != nil { + t.Fatalf("marshal metadata: %v", err) + } + if err := os.WriteFile(m.metadataPath("Project"), staleData, 0o600); err != nil { + t.Fatalf("write stale metadata: %v", err) + } + + got, err := m.GetWorkspaceMetadata("Project") + if err != nil { + t.Fatalf("GetWorkspaceMetadata: %v", err) + } + if got.WorkspaceName != "Project" { + t.Fatalf("workspaceName = %q, want canonical folder name Project", got.WorkspaceName) + } +} + +func TestRenameWorkspacePhysicallyRenamesFolderAndMetadata(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + if _, err := m.CreateWorkspace("Project", "default"); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + if err := m.RenameWorkspace("Project", "Renamed"); err != nil { + t.Fatalf("RenameWorkspace: %v", err) + } + if _, err := os.Stat(filepath.Join(vaultDir, "Project")); !os.IsNotExist(err) { + t.Fatalf("old folder still exists or stat failed unexpectedly: %v", err) + } + if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); err != nil { + t.Fatalf("renamed folder missing: %v", err) + } + + meta, err := m.GetWorkspaceMetadata("Renamed") + if err != nil { + t.Fatalf("metadata after rename: %v", err) + } + if meta.WorkspaceName != "Renamed" { + t.Fatalf("metadata workspaceName = %q, want Renamed", meta.WorkspaceName) + } + if _, err := os.Stat(m.metadataPath("Project")); !os.IsNotExist(err) { + t.Fatalf("old metadata key still exists or stat failed unexpectedly: %v", err) + } +} + +func TestTrashWorkspaceMovesFolderToTrashAndRemovesFromList(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + if _, err := m.CreateWorkspace("Project", "default"); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + result, err := m.TrashWorkspace("Project") + if err != nil { + t.Fatalf("TrashWorkspace: %v", err) + } + if result.OriginalPath != "Project" || result.TrashID == "" || result.TrashPath == "" { + t.Fatalf("bad trash result: %+v", result) + } + if _, err := os.Stat(filepath.Join(vaultDir, "Project")); !os.IsNotExist(err) { + t.Fatalf("workspace still exists after trash, stat err=%v", err) + } + if _, err := os.Stat(filepath.Join(vaultDir, filepath.FromSlash(result.TrashPath))); err != nil { + t.Fatalf("trashed workspace missing: %v", err) + } + + workspaces, err := m.ListWorkspaces() + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(workspaces) != 0 { + t.Fatalf("workspace should be removed from list after trash, got %+v", workspaces) + } +} + +func TestCreateAndRenameConflictsAreExplicit(t *testing.T) { + vaultDir := newVaultDir(t) + mustMkdir(t, filepath.Join(vaultDir, "Existing")) + mustMkdir(t, filepath.Join(vaultDir, "Other")) + m := NewManager(vaultDir) + + if _, err := m.CreateWorkspace("Existing", ""); err == nil || !strings.Contains(err.Error(), "conflict") { + t.Fatalf("create conflict error = %v, want conflict", err) + } + if err := m.RenameWorkspace("Existing", "Other"); err == nil || !strings.Contains(err.Error(), "conflict") { + t.Fatalf("rename conflict error = %v, want conflict", err) + } +} + +func TestInvalidWorkspaceNamesRejected(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + + names := []string{"", " ", "A/B", `A\B`, "/abs", `C:\abs`, "..", "a..b", "bad\x00name", ".verstak", ".Verstak", ".git"} + for _, name := range names { + if _, err := m.CreateWorkspace(name, ""); err == nil { + t.Fatalf("CreateWorkspace(%q) succeeded, want invalid name error", name) + } + } +} + +func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) { + vaultDir := newVaultDir(t) + mustMkdir(t, filepath.Join(vaultDir, "Project")) + mustMkdir(t, filepath.Join(vaultDir, "Project", "Nested")) + mustMkdir(t, filepath.Join(vaultDir, "Test")) + + m := NewManager(vaultDir) + if err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } tree := m.GetTree() if len(tree.Nodes) != 2 { - t.Errorf("expected 2 nodes, got %d", len(tree.Nodes)) + t.Fatalf("nodes = %+v, want 2 top-level workspaces", tree.Nodes) + } + if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" || tree.Nodes[0].Path != "" { + t.Fatalf("first compatibility node = %+v, want derived workspace without persisted path mapping", tree.Nodes[0]) + } + for _, node := range tree.Nodes { + if node.ParentID != "" { + t.Fatalf("compatibility tree should be flat, got child node %+v", node) + } + if node.ID == "Nested" || node.Title == "Nested" { + t.Fatalf("nested folders must not become workspace nodes: %+v", tree.Nodes) + } } } -func TestCreateNode_DuplicateTitlesGetUniquePaths(t *testing.T) { - dir := t.TempDir() - vaultDir := filepath.Join(dir, "vault") - os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) +func TestMoveNodeCompatibilityDoesNotCreateNestedWorkspaceModel(t *testing.T) { + vaultDir := newVaultDir(t) + mustMkdir(t, filepath.Join(vaultDir, "Project")) + mustMkdir(t, filepath.Join(vaultDir, "Test")) m := NewManager(vaultDir) if err := m.Load(); err != nil { t.Fatalf("Load: %v", err) } + err := m.MoveNode("Project", "Test") + if err == nil || !strings.Contains(err.Error(), "top-level only") { + t.Fatalf("MoveNode error = %v, want top-level only", err) + } + if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(statErr) { + t.Fatalf("MoveNode created nested mapped workspace, stat err=%v", statErr) + } +} - rootID := m.GetTree().Nodes[0].ID - first, err := m.CreateNode(rootID, TypeCase, "SameName") +func TestMetadataFileShape(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + if _, err := m.CreateWorkspace("Project", "default"); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + data, err := os.ReadFile(m.metadataPath("Project")) if err != nil { - t.Fatalf("CreateNode first: %v", err) + t.Fatalf("read metadata: %v", err) } - second, err := m.CreateNode(rootID, TypeCase, "SameName") - if err != nil { - t.Fatalf("CreateNode second: %v", err) + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("metadata JSON: %v", err) } - - if first.Path == second.Path { - t.Fatalf("expected unique paths, got %q", first.Path) + if raw["workspaceName"] != "Project" { + t.Fatalf("workspaceName = %v", raw["workspaceName"]) } - 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)")) + if _, ok := raw["createdFromTemplate"].(map[string]interface{}); !ok { + t.Fatalf("createdFromTemplate missing in raw metadata: %s", data) } } -func TestCreateNode_InvalidType(t *testing.T) { - dir := t.TempDir() - vaultDir := filepath.Join(dir, "vault") - os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) +func newVaultDir(t *testing.T) string { + t.Helper() + vaultDir := filepath.Join(t.TempDir(), "vault") + mustMkdir(t, vaultDir) + mustMkdir(t, filepath.Join(vaultDir, ".verstak", "trash")) + return vaultDir +} - m := NewManager(vaultDir) - m.Load() - - _, err := m.CreateNode("", NodeType("note"), "My Note") - if err == nil { - t.Error("expected error for invalid type 'note'") +func mustMkdir(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("MkdirAll(%s): %v", path, err) } } -func TestCreateNode_EmptyTitle(t *testing.T) { - dir := t.TempDir() - vaultDir := filepath.Join(dir, "vault") - os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) - - m := NewManager(vaultDir) - m.Load() - - _, err := m.CreateNode("", TypeCase, "") - if err == nil { - t.Error("expected error for empty title") +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err) } - _, err = m.CreateNode("", TypeCase, " ") - if err == nil { - t.Error("expected error for whitespace-only title") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", path, err) } } -func TestRenameNode(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, "Original") - - if err := m.RenameNode(node.ID, "Renamed"); err != nil { - t.Fatalf("RenameNode: %v", err) - } - - renamed, _ := m.GetNode(node.ID) - if renamed.Title != "Renamed" { - t.Errorf("title: got %q, want %q", renamed.Title, "Renamed") - } - if renamed.UpdatedAt == node.UpdatedAt { - t.Error("updatedAt should change after rename") - } -} - -func TestMoveNode(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 - folder, _ := m.CreateNode(rootID, TypeFolder, "Folder") - c, _ := m.CreateNode(rootID, TypeCase, "Case") - - // Move case into folder - if err := m.MoveNode(c.ID, folder.ID); err != nil { - t.Fatalf("MoveNode: %v", err) - } - - moved, _ := m.GetNode(c.ID) - 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) { - 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") - - err := m.MoveNode(node.ID, node.ID) - if err == nil { - t.Error("expected error when moving node into itself") - } -} - -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") - os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) - - m := NewManager(vaultDir) - m.Load() - - rootID := m.GetTree().Nodes[0].ID - folder, _ := m.CreateNode(rootID, TypeFolder, "Folder") - child, _ := m.CreateNode(folder.ID, TypeCase, "Child") - - // Try to move folder into its own child - err := m.MoveNode(folder.ID, child.ID) - if err == nil { - t.Error("expected error when moving node into descendant") - } -} - -func TestArchiveNode(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, "To Archive") - - if err := m.ArchiveNode(node.ID); err != nil { - t.Fatalf("ArchiveNode: %v", err) - } - - archived, _ := m.GetNode(node.ID) - if archived.Status != StatusArchived { - t.Errorf("status: got %s, want %s", archived.Status, StatusArchived) - } -} - -func TestSetCurrentNode(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, "My Case") - - if err := m.SetCurrentNode(node.ID); err != nil { - t.Fatalf("SetCurrentNode: %v", err) - } - - current, err := m.GetCurrentNode() - if err != nil { - t.Fatalf("GetCurrentNode: %v", err) - } - if current.ID != node.ID { - t.Errorf("current: got %s, want %s", current.ID, node.ID) - } -} - -func TestGetTree_StableAfterReopen(t *testing.T) { - dir := t.TempDir() - vaultDir := filepath.Join(dir, "vault") - os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) - - // Create and populate - m1 := NewManager(vaultDir) - m1.Load() - rootID := m1.GetTree().Nodes[0].ID - m1.CreateNode(rootID, TypeCase, "Case 1") - m1.CreateNode(rootID, TypeFolder, "Folder 1") - m1.CreateNode(rootID, TypeCase, "Case 2") - - // Reopen - m2 := NewManager(vaultDir) - if err := m2.Load(); err != nil { - t.Fatalf("reopen Load: %v", err) - } - - tree := m2.GetTree() - // root + 3 created = 4 - if len(tree.Nodes) != 4 { - t.Fatalf("expected 4 nodes after reopen, got %d", len(tree.Nodes)) - } - - // Check order: children of root should be sorted by order - children := m2.ListChildren(rootID) - if len(children) != 3 { - t.Fatalf("expected 3 children, got %d", len(children)) - } - if children[0].Title != "Case 1" { - t.Errorf("first child: got %q, want %q", children[0].Title, "Case 1") - } - if children[1].Title != "Folder 1" { - t.Errorf("second child: got %q, want %q", children[1].Title, "Folder 1") - } - if children[2].Title != "Case 2" { - t.Errorf("third child: got %q, want %q", children[2].Title, "Case 2") - } -} - -func TestCorruptWorkspaceJSON(t *testing.T) { - dir := t.TempDir() - vaultDir := filepath.Join(dir, "vault") - verstakDir := filepath.Join(vaultDir, ".verstak") - os.MkdirAll(verstakDir, 0o755) - - // Write corrupt JSON - corruptPath := filepath.Join(verstakDir, "workspace.json") - os.WriteFile(corruptPath, []byte("{not valid json"), 0o600) - - m := NewManager(vaultDir) - err := m.Load() - if err == nil { - t.Error("expected error for corrupt workspace.json") - } - - // Should have created a backup - entries, _ := os.ReadDir(verstakDir) - backupFound := false - for _, e := range entries { - if filepath.Ext(e.Name()) == ".corrupt" || len(e.Name()) > 14 && e.Name()[14] == '-' { - backupFound = true - break - } - } - // Also check for .corrupt.* pattern - for _, e := range entries { - name := e.Name() - if len(name) > 20 && name[:14] == "workspace.json" { - backupFound = true - break - } - } - _ = backupFound // backup may have different naming - - // Should have created a valid default tree - tree := m.GetTree() - if len(tree.Nodes) != 1 { - t.Errorf("expected 1 default node, got %d", len(tree.Nodes)) - } -} - -func TestListChildren_EmptyParent(t *testing.T) { - dir := t.TempDir() - vaultDir := filepath.Join(dir, "vault") - os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) - - m := NewManager(vaultDir) - m.Load() - - // Root has no parent, so ListChildren("") should return root-level nodes - children := m.ListChildren("") - if len(children) != 1 { - t.Errorf("expected 1 root-level node, got %d", len(children)) - } -} - -func TestCreateNode_InvalidParent(t *testing.T) { - dir := t.TempDir() - vaultDir := filepath.Join(dir, "vault") - os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) - - m := NewManager(vaultDir) - m.Load() - - _, err := m.CreateNode("nonexistent-id", TypeCase, "Orphan") - if err == nil { - t.Error("expected error for nonexistent parent") +func workspaceNames(workspaces []Workspace) []string { + names := make([]string, len(workspaces)) + for i, ws := range workspaces { + names[i] = ws.Name } + return names }