core: Milestone 7b — Files explorer and Default Editor improvements

- 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
This commit is contained in:
mirivlad 2026-06-20 19:20:13 +08:00
parent 4de5a74a55
commit 0ac473d720
18 changed files with 1956 additions and 1161 deletions

View File

@ -15,7 +15,7 @@ Already available:
- Plugin discovery, lifecycle, settings, capabilities, bundled commands, and - Plugin discovery, lifecycle, settings, capabilities, bundled commands, and
bundled frontend events. 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: - Plugin-owned internal storage directories:
`.verstak/plugin-data/<pluginId>`, `.verstak/plugin-settings/<pluginId>`, and `.verstak/plugin-data/<pluginId>`, `.verstak/plugin-settings/<pluginId>`, and
`.verstak/plugin-cache/<pluginId>`. `.verstak/plugin-cache/<pluginId>`.
@ -54,11 +54,11 @@ Canonical rules:
Canonical scoped paths: Canonical scoped paths:
- Workspace/root overview notes live under `<workspace-node-path>/Notes/`. - Workspace overview notes live under `<workspace>/Notes/`.
- Case/project/folder scoped notes live under `<workspace-node-path>/Notes/`. - The default overview note is `<workspace>/Notes/Overview.md`.
- The default overview note is `<workspace-node-path>/Notes/Overview.md`. - `<workspace>` is the top-level physical folder name under the vault root.
- `workspace-node-path` is a normal vault-relative folder path stored on the - Files plugin workspace views are scoped with `workspaceRootPath`, which is the
workspace node. Files plugin workspace views are scoped to this path. selected top-level workspace folder name.
Visibility requirements: Visibility requirements:
@ -67,10 +67,9 @@ Visibility requirements:
- External file managers must show the same `.md` files. - External file managers must show the same `.md` files.
- Outside Verstak, the files must remain useful as normal Markdown. - Outside Verstak, the files must remain useful as normal Markdown.
The workspace tree can remain `space`/`case`/`folder`. Adding `note` as a There is no canonical metadata workspace tree. Adding `note` as a workspace node
workspace node type is not part of the next milestone because it would require a type is not part of the next milestone. The Notes service can index and manage
schema migration. The Notes service can index and manage Markdown files inside Markdown files inside canonical `Notes/` folders under each top-level workspace.
canonical `Notes/` folders without changing workspace node types.
## Title To Filename Contract ## 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: The same physical note must be visible through both APIs:
- Files sees `SomeCase/Notes/Overview.md` as a file. - Files sees `Project/Notes/Overview.md` as a file.
- Notes sees `SomeCase/Notes/Overview.md` as a note with title `Overview`. - 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, 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 or a separate `.verstak` note database. Indexes and caches may exist later, but

View File

@ -750,51 +750,104 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json) - `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
- `./scripts/build.sh` — ✅ - `./scripts/build.sh` — ✅
## Workspace / Cases Core Capability ## Workspace Core Capability
Workspace — центральная модель Верстака вокруг "дел". Это НЕ notes/files — это фундамент. Workspace — это физическая папка верхнего уровня внутри vault root. Filesystem
является source of truth для списка workspaces.
### Ноды
| Поле | Тип | Описание |
|------|-----|----------|
| `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 | Обновлён |
### Хранение
`<vault>/.verstak/workspace.json` — атомарная запись metadata (temp + rename).
Каждая workspace node также имеет user-visible folder inside vault. `path`
хранит canonical vault-relative folder path. Имена папок читаемые: берутся из
title, очищаются от запрещённых символов, сохраняют Unicode/кириллицу, а при
коллизии получают suffix ` (2)`, ` (3)`, ...
Пример: Пример:
``` ```
<vault>/ <vault>/
My Workspace/ Workspace/
Test/ Notes/
test/ Overview.md
Project/
ClientA/
.verstak/
``` ```
Нет единого `<vault>/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 ### API
- `GetWorkspaceTree()` — полное дерево - `ListWorkspaces()` — список top-level physical folders.
- `CreateWorkspaceNode(parentID, type, title)` — создать - `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
- `RenameWorkspaceNode(id, title)` — переименовать template один раз, сохранить snapshot metadata.
- `MoveWorkspaceNode(id, newParentID)` — переместить - `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder
- `ArchiveWorkspaceNode(id)` — архивировать и обновить metadata key/name.
- `SetCurrentWorkspaceNode(id)` — выбрать текущую - `TrashWorkspace(name)` — перенести весь top-level workspace folder в internal
- `GetCurrentWorkspaceNode()` — получить текущую 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 ### Capability
@ -802,28 +855,16 @@ title, очищаются от запрещённых символов, сохр
### Правила ### Правила
- Root node создаётся при создании vault - Workspace name — один safe folder name, не path.
- Для каждой node создаётся обычная папка внутри vault - Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
- WorkspaceItems получают выбранную node и `workspaceRootPath`; Files plugin `.verstak`, reserved/internal names, symlink workspaces, conflicts.
показывает именно эту папку, а не общий root vault - WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки
- Порядок children стабилен (sort by order) (`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
- Нельзя переместить ноду в себя или в своего потомка - Files API остаётся raw vault-relative API: `Project/Notes/Overview.md`,
- `MoveWorkspaceNode` переносит physical folder subtree and updates descendant `Project/docs/file.md`, `Test/readme.md`.
paths - Notes are ordinary Markdown files under `<workspace>/Notes/`; нет
- `RenameWorkspaceNode` меняет display title; physical folder rename/UI для этого `.verstak/notes`, UUID note storage или второго source of truth для note
остаётся отдельным действием content.
- Архивирование — soft delete (status = archived)
- Corrupt JSON → backup + defaults
### Типы нод
| Тип | Назначение |
|-----|-----------|
| `space` | Рабочее пространство (root) |
| `case` | Дело |
| `folder` | Папка |
НЕ добавляются: note, file, action, secret, worklog, link — это плагины.
### Lifecycle Events ### Lifecycle Events
@ -837,11 +878,11 @@ title, очищаются от запрещённых символов, сохр
### UI ### UI
WorkspaceTree в sidebar: Workspace list в sidebar:
- Дерево с expand/collapse - Flat list of top-level workspace folders.
- Создание case/folder - Create workspace, rename workspace, trash workspace.
- Выбор текущей ноды - Selection is stored as selected workspace name.
- Индикатор статуса (active/archived/sleeping) - No expand/collapse workspace tree and no case/folder node creation in core.
--- ---

View File

@ -36,6 +36,67 @@ test.describe('F: Default Editor Plugin', () => {
await expect(textarea).toHaveValue('Buy groceries\nWrite tests'); 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 }) => { test('open .md file outside Notes routes to highest-priority provider', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', { const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {

View File

@ -28,16 +28,65 @@ test.describe('G: Files Plugin', () => {
}); });
test('workspace Files view is scoped to selected workspace folder', async ({ page }) => { 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('.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: 'project-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: '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: 'test-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: '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 }) => { 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 }) => { test('files plugin card shows openProviders in contributions', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', { const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
kind: 'vault-file', path: 'test.txt', extension: '.txt', kind: 'vault-file', path: 'Docs/todo.txt', extension: '.txt',
context: { sourcePluginId: 'verstak.files', sourceView: 'files' }, context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
}); });
if (err) throw new Error(err); if (err) throw new Error(err);

View File

@ -74,12 +74,33 @@ test.describe('E: Plugin Manager layout', () => {
test('workspace selection keeps exactly one active node', async ({ page }) => { test('workspace selection keeps exactly one active node', async ({ page }) => {
const selected = page.locator('.wt-node.selected .wt-label'); const selected = page.locator('.wt-node.selected .wt-label');
await expect(selected).toHaveCount(1); 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).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 }) => { 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).toBeVisible();
await expect(logo).toHaveClass(/lucide/); 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(); const workspaceIcon = page.locator('.wt-node-icon').first();
await expect(workspaceIcon).toBeVisible(); await expect(workspaceIcon).toBeVisible();
await expect(workspaceIcon).toHaveClass(/lucide/); await expect(workspaceIcon).toHaveClass(/lucide/);

View File

@ -22,7 +22,7 @@
let openedResource = null; let openedResource = null;
let workspaceNodes = []; let workspaceNodes = [];
let currentWorkspaceNodeId = ''; let selectedWorkspaceName = '';
function flog(msg) { function flog(msg) {
App.WriteFrontendLog('App', msg); App.WriteFrontendLog('App', msg);
@ -95,11 +95,11 @@
currentView = 'workbench'; currentView = 'workbench';
} }
function onWorkspaceNodeSelected(e) { function onWorkspaceSelected(e) {
debug.log('[App] onWorkspaceNodeSelected:', e.detail?.nodeId); debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName);
currentWorkspaceNodeId = e.detail?.nodeId || ''; selectedWorkspaceName = e.detail?.workspaceName || '';
workspaceNodes = e.detail?.nodes || workspaceNodes; workspaceNodes = e.detail?.nodes || workspaceNodes;
if (currentWorkspaceNodeId) { if (selectedWorkspaceName) {
currentView = 'workspace'; currentView = 'workspace';
} }
} }
@ -118,7 +118,7 @@
window.addEventListener('verstak:open-settings', onOpenSettings); window.addEventListener('verstak:open-settings', onOpenSettings);
window.addEventListener('verstak:close-settings', onCloseSettings); window.addEventListener('verstak:close-settings', onCloseSettings);
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened); window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
window.addEventListener('verstak:workspace-node-selected', onWorkspaceNodeSelected); window.addEventListener('verstak:workspace-selected', onWorkspaceSelected);
} }
onMount(() => { checkVault(); }); onMount(() => { checkVault(); });
@ -140,7 +140,7 @@
{:else if currentView === 'workbench'} {:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} /> <WorkbenchHost {openedResource} />
{:else if currentView === 'workspace'} {:else if currentView === 'workspace'}
<WorkspaceHost currentNodeId={currentWorkspaceNodeId} nodes={workspaceNodes} /> <WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} />
{:else} {:else}
<ViewContainer {activeView} {activeViewPluginId} /> <ViewContainer {activeView} {activeViewPluginId} />
{/if} {/if}

View File

@ -2,15 +2,16 @@
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte'; import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
export let currentNodeId = ''; export let selectedWorkspaceName = '';
export let nodes = []; export let nodes = [];
let contributions = {}; let contributions = {};
let plugins = []; let plugins = [];
let workspaceTools = []; let workspaceTools = [];
$: currentNode = nodes.find(n => n.id === currentNodeId) || null; $: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
$: if (currentNodeId) loadTools(); $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: if (selectedWorkspaceName) loadTools();
async function loadTools() { async function loadTools() {
try { try {
@ -33,10 +34,10 @@
</script> </script>
<div class="workspace-host"> <div class="workspace-host">
{#if currentNode} {#if selectedWorkspace}
<div class="workspace-header"> <div class="workspace-header">
<span class="workspace-title">{currentNode.title}</span> <span class="workspace-title">{selectedWorkspace.title}</span>
<span class="workspace-type">{currentNode.type}</span> <span class="workspace-type">{selectedWorkspace.type}</span>
</div> </div>
{#if workspaceTools.length > 0} {#if workspaceTools.length > 0}
@ -51,7 +52,7 @@
<PluginBundleHost <PluginBundleHost
pluginId={tool.pluginId} pluginId={tool.pluginId}
componentId={tool.component} componentId={tool.component}
componentProps={{ workspaceNodeId: currentNodeId, workspaceNode: currentNode, workspaceRootPath: currentNode.path || '' }} componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath }}
/> />
</div> </div>
</div> </div>

View File

@ -124,7 +124,7 @@
icon: 'folder', icon: 'folder',
provides: ['verstak/files/v1'], provides: ['verstak/files/v1'],
requires: ['verstak/core/files/v1', 'verstak/core/workbench/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' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }], views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }],
@ -152,11 +152,10 @@
function makeDefaultWorkspaceTree() { function makeDefaultWorkspaceTree() {
return { return {
status: 'initialized', status: 'initialized',
currentNodeId: 'case-alpha', currentNodeId: 'Project',
nodes: [ nodes: [
{ id: 'space-main', parentId: '', type: 'space', title: 'Main Space', path: 'Main Space', status: 'active', order: 1 }, { id: 'Project', parentId: '', type: 'space', title: 'Project', name: 'Project', rootPath: 'Project', 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: 'Test', parentId: '', type: 'space', title: 'Test', name: 'Test', rootPath: 'Test', status: 'active', order: 2 }
{ id: 'case-beta', parentId: 'space-main', type: 'case', title: 'Beta Case', path: 'Main Space/Beta Case', 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() { function makeDefaultVaultFiles() {
return { return {
'': { type: 'folder', modifiedAt: new Date().toISOString() }, '': { 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() }, 'Docs/readme.md': { type: 'file', content: '# Hello World\n\nThis is a **test** document.\n\n- item 1\n- item 2', modifiedAt: new Date().toISOString() },
'Notes': { type: 'folder', modifiedAt: new Date().toISOString() }, 'Notes': { type: 'folder', modifiedAt: new Date().toISOString() },
'Notes/Overview.md': { type: 'file', content: '# Notes Overview\n\nMy notes content here.', modifiedAt: new Date().toISOString() }, 'Notes/Overview.md': { type: 'file', content: '# Notes Overview\n\nMy notes content here.', modifiedAt: new Date().toISOString() },
'Main Space': { type: 'folder', modifiedAt: new Date().toISOString() }, 'Project': { type: 'folder', modifiedAt: new Date().toISOString() },
'Main Space/Alpha Case': { type: 'folder', modifiedAt: new Date().toISOString() }, 'Project/Notes': { type: 'folder', modifiedAt: new Date().toISOString() },
'Main Space/Alpha Case/alpha-only.txt': { type: 'file', content: 'alpha file', modifiedAt: new Date().toISOString() }, 'Project/Notes/Overview.md': { type: 'file', content: '# Project Overview\n', modifiedAt: new Date().toISOString() },
'Main Space/Beta Case': { type: 'folder', modifiedAt: new Date().toISOString() }, 'Project/project-only.txt': { type: 'file', content: 'project file', modifiedAt: new Date().toISOString() },
'Main Space/Beta Case/beta-only.txt': { type: 'file', content: 'beta 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() { function defaultEditorBundle() {
return [ return '(' + function () {
'(function(){', function e(tag, attrs, children) {
'var DefaultEditor={', var node = document.createElement(tag);
'mount:function(c,p,api){', attrs = attrs || {};
'if(!document.getElementById("mock-default-editor-styles")){', Object.keys(attrs).forEach(function (key) {
'var style=document.createElement("style");', if (key === 'className') node.className = attrs[key];
'style.id="mock-default-editor-styles";', else if (key.indexOf('on') === 0) node.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
'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}";', else node.setAttribute(key, attrs[key]);
'document.head.appendChild(style);', });
'}', (children || []).forEach(function (child) { node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); });
'c.innerHTML="";', return node;
'c.className="de-root";', }
'var req=p.request||{};', function esc(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
'var path=req.path||"";', function renderMarkdown(text) {
'var mode=req.mode||"view";', return String(text || '').split(/\n/).map(function (line) {
'var ctx=req.context||{};', if (/^#\s+/.test(line)) return '<h1>' + esc(line.replace(/^#\s+/, '')) + '</h1>';
'var isNotes=ctx.notesMode||ctx.isInsideNotesFolder;', if (/^-\s+\[[ x]\]\s+/i.test(line)) return '<ul><li><input type="checkbox" disabled> ' + esc(line.replace(/^-\s+\[[ x]\]\s+/i, '')) + '</li></ul>';
'var ext=(req.extension||"").toLowerCase();', if (/^-\s+/.test(line)) return '<ul><li>' + esc(line.replace(/^-\s+/, '')) + '</li></ul>';
'var isMd=ext===".md"||ext===".markdown";', return line ? '<p>' + esc(line).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') + '</p>' : '';
'var editorMode=isNotes?"notes-markdown":isMd?"generic-markdown":"text";', }).join('');
'c.setAttribute("data-editor-mode",editorMode);', }
'c.setAttribute("data-resource-path",path);', function insertAround(ta, before, after, fallback) {
'c.setAttribute("data-request-mode",mode);', var start = ta.selectionStart;
'var toolbar=document.createElement("div");', var end = ta.selectionEnd;
'toolbar.className="de-toolbar";', var text = ta.value.slice(start, end) || fallback || '';
'var modeLabel=document.createElement("span");', ta.value = ta.value.slice(0, start) + before + text + after + ta.value.slice(end);
'modeLabel.className="de-toolbar-mode";', ta.selectionStart = start + before.length;
'modeLabel.textContent=editorMode;', ta.selectionEnd = start + before.length + text.length;
'toolbar.appendChild(modeLabel);', ta.dispatchEvent(new Event('input', { bubbles: true }));
'var pathLabel=document.createElement("span");', }
'pathLabel.className="de-toolbar-context";', var DefaultEditor = {
'pathLabel.textContent=path;', mount: function (c, p, api) {
'toolbar.appendChild(pathLabel);', if (!document.getElementById('mock-default-editor-styles')) {
'if(isNotes){var badge=document.createElement("span");badge.className="de-notes-badge";badge.textContent="notes context";badge.setAttribute("data-notes-badge","");toolbar.appendChild(badge);}', var style = document.createElement('style');
'c.appendChild(toolbar);', style.id = 'mock-default-editor-styles';
'var content=document.createElement("div");', 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}';
'content.className="de-editor-wrap";', document.head.appendChild(style);
'content.textContent="Loading...";', }
'c.appendChild(content);', c.innerHTML = '';
'api.files.readText(path).then(function(text){', c.className = 'de-root';
'content.textContent="";', var req = p.request || {};
'if(isMd){', var path = req.path || '';
'var preview=document.createElement("div");', var ctx = req.context || {};
'preview.className="de-preview";', var isNotes = ctx.notesMode || ctx.isInsideNotesFolder;
'preview.setAttribute("data-preview","");', var ext = (req.extension || '').toLowerCase();
'preview.textContent=text;', var isMd = ext === '.md' || ext === '.markdown';
'content.appendChild(preview);', var editorMode = isNotes ? 'notes-markdown' : isMd ? 'generic-markdown' : 'text';
'}else{', var viewMode = isMd && req.mode !== 'edit' ? 'preview' : 'edit';
'var ta=document.createElement("textarea");', var current = '';
'ta.className="de-textarea";', var saved = '';
'ta.value=text;', var dirty = false;
'ta.setAttribute("data-editor-textarea","");', var ta = null;
'content.appendChild(ta);', var preview = null;
'}', var status = e('span', { className: 'de-status', 'data-save-state': '' }, []);
'}).catch(function(err){', c.setAttribute('data-editor-mode', editorMode);
'content.textContent="Error: "+(err.message||err);', 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])]);
'unmount:function(c){c.innerHTML="";}', if (isNotes) toolbar.appendChild(e('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, ['notes context']));
'};', toolbar.appendChild(e('span', { className: 'de-toolbar-spacer' }, []));
'window.VerstakPluginRegister("verstak.default-editor",{components:{DefaultEditor:DefaultEditor}});', ['edit', 'preview', 'split'].forEach(function (mode) {
'})();' if (!isMd) return;
].join('\n'); 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() { function filesPluginBundle() {
return [ return '(' + function () {
"(function(){", function e(tag, attrs, children) {
"var FilesView={", var node = document.createElement(tag);
"mount:function(c,p,api){", attrs = attrs || {};
"c.innerHTML='';", Object.keys(attrs).forEach(function (key) {
"c.className='files-root';", if (key === 'className') node.className = attrs[key];
"c.setAttribute('data-plugin-id','verstak.files');", else if (key.indexOf('on') === 0) node.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
"var root=String((p&&(p.workspaceRootPath||(p.workspaceNode&&p.workspaceNode.path)))||'').split('/').filter(Boolean).join('/');", else node.setAttribute(key, attrs[key]);
"var list=document.createElement('div');", });
"list.className='files-list';", (children || []).forEach(function (child) { if (child) node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); });
"list.setAttribute('data-files-list','');", return node;
"c.appendChild(list);", }
"function load(){", function clean(path) { return String(path || '').split('/').filter(Boolean).join('/'); }
"list.textContent='Loading...';", function parent(path) { path = clean(path); var i = path.lastIndexOf('/'); return i < 0 ? '' : path.slice(0, i); }
"api.files.list(root).then(function(entries){", function base(path) { path = clean(path); var i = path.lastIndexOf('/'); return i < 0 ? path : path.slice(i + 1); }
"list.innerHTML='';", function ext(name) { var i = String(name || '').lastIndexOf('.'); return i > 0 ? name.slice(i + 1).toLowerCase() : ''; }
"if(!entries||!entries.length){list.textContent='Empty folder';return;}", var FilesView = {
"entries.forEach(function(e){", mount: function (c, p, api) {
"if(e.isHidden||e.isReserved)return;", c.innerHTML = '';
"var item=document.createElement('div');", c.className = 'files-root';
"item.className='files-item';", c.setAttribute('tabindex', '0');
"item.setAttribute('data-file-name',e.name);", c.setAttribute('data-plugin-id', 'verstak.files');
"item.setAttribute('data-file-type',e.type);", var n = p && p.workspaceNode;
"item.setAttribute('data-file-path',e.relativePath);", var root = clean((p && (p.workspaceRootPath || (n && (n.rootPath || n.name || n.id)))) || '');
"var icon=document.createElement('span');", var workspaceName = root || 'Workspace';
"icon.className='files-item-icon';", var current = '';
"icon.textContent=e.type==='folder'?'[D]':'[F]';", var entries = [];
"var name=document.createElement('span');", var selected = '';
"name.className='files-item-name';", var filter = '';
"name.textContent=e.name;", var sort = 'folder-name';
"item.appendChild(icon);", var createMode = '';
"item.appendChild(name);", var renaming = null;
"if(e.type!=='folder'){", function scoped(local) { local = clean(local); return root ? (local ? root + '/' + local : root) : local; }
"item.addEventListener('dblclick',function(){", function local(full) { full = clean(full); return root && full.indexOf(root + '/') === 0 ? full.slice(root.length + 1) : full === root ? '' : full; }
"var ext=e.extension?'.'+e.extension:'';", var toolbar = e('div', { className: 'files-toolbar' }, []);
"var ctx={sourcePluginId:'verstak.files',sourceView:'files'};", var breadcrumb = e('div', { className: 'files-breadcrumb' }, []);
"api.workbench.openResource({kind:'vault-file',path:e.relativePath,mode:'view',extension:ext,context:ctx});", 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)); }));
"list.appendChild(item);", toolbar.appendChild(btn('Refresh', 'refresh', load));
"});", toolbar.appendChild(btn('+ Folder', 'new-folder', function () { startCreate('folder'); }));
"}).catch(function(err){list.textContent='Error: '+(err.message||err);});", toolbar.appendChild(btn('+ Markdown', 'new-markdown', function () { startCreate('markdown'); }));
"}", toolbar.appendChild(btn('+ Text', 'new-text', function () { startCreate('text'); }));
"load();", toolbar.appendChild(btn('Open', 'open', function () { open(entryByPath(selected)); }));
"},", toolbar.appendChild(btn('Rename', 'rename', function () { startRename(entryByPath(selected)); }));
"unmount:function(c){c.innerHTML='';}", toolbar.appendChild(btn('Trash', 'trash', function () { trash(entryByPath(selected)); }));
"};", var filterInput = e('input', { className: 'files-filter', 'data-files-filter': '', placeholder: 'Filter current folder' }, []);
"window.VerstakPluginRegister('verstak.files',{components:{FilesView:FilesView}});", filterInput.addEventListener('input', function () { filter = filterInput.value.toLowerCase(); render(); });
"})();" toolbar.appendChild(filterInput);
].join('\n'); 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() { function platformTestBundle() {
@ -780,18 +943,92 @@
moving.forEach(function (path) { delete vaultFiles[path]; }); moving.forEach(function (path) { delete vaultFiles[path]; });
return Promise.resolve([{ originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString() }, '']); 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()); }, GetWorkspaceTree: function () { return Promise.resolve(cloneWorkspaceTree()); },
ArchiveWorkspaceNode: function () { return Promise.resolve(''); }, ArchiveWorkspaceNode: function (id) { return this.TrashWorkspace(id).then(function (response) { return typeof response === 'string' ? response : ''; }); },
CreateWorkspaceNode: function () { return Promise.resolve({}); }, 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(''); }, MoveWorkspaceNode: function () { return Promise.resolve(''); },
RenameWorkspaceNode: function () { return Promise.resolve(''); }, RenameWorkspaceNode: function (id, title) { return this.RenameWorkspace(id, title); },
SetCurrentWorkspaceNode: function (id) { SetCurrentWorkspace: function (id) {
var found = workspaceTree.nodes.some(function (n) { return n.id === 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; workspaceTree.currentNodeId = id;
return Promise.resolve(''); return Promise.resolve('');
}, },
SetCurrentWorkspaceNode: function (id) { return this.SetCurrentWorkspace(id); },
SelectDirectory: function () { return Promise.resolve(''); }, SelectDirectory: function () { return Promise.resolve(''); },
SelectVaultForOpen: function () { return Promise.resolve(''); }, SelectVaultForOpen: function () { return Promise.resolve(''); },
CreateVault: function () { return Promise.resolve(null); }, CreateVault: function () { return Promise.resolve(null); },
@ -962,7 +1199,7 @@
icon: 'folder', icon: 'folder',
provides: ['verstak/files/v1'], provides: ['verstak/files/v1'],
requires: ['verstak/core/files/v1', 'verstak/core/workbench/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' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }], views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }],

View File

@ -25,6 +25,7 @@
import Settings from 'lucide-svelte/icons/settings'; import Settings from 'lucide-svelte/icons/settings';
import Shield from 'lucide-svelte/icons/shield'; import Shield from 'lucide-svelte/icons/shield';
import TriangleAlert from 'lucide-svelte/icons/triangle-alert'; import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
import Trash2 from 'lucide-svelte/icons/trash-2';
export let name = 'dot'; export let name = 'dot';
export let size = 16; export let size = 16;
@ -43,6 +44,7 @@
plugin: Plug, plugin: Plug,
puzzle: Puzzle, puzzle: Puzzle,
space: LayoutGrid, space: LayoutGrid,
trash: Trash2,
vault: Shield, vault: Shield,
warning: TriangleAlert, warning: TriangleAlert,
}; };

View File

@ -14,5 +14,24 @@ export default defineConfig(({ mode }) => {
port: isTest ? 5174 : 5173, port: isTest ? 5174 : 5173,
strictPort: true, 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',
],
},
}; };
}); });

View File

@ -1,5 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {workspace} from '../models';
import {workbench} from '../models'; import {workbench} from '../models';
import {capability} from '../models'; import {capability} from '../models';
import {api} from '../models'; import {api} from '../models';
@ -15,6 +16,8 @@ export function CreateVault(arg1:string):Promise<void>;
export function CreateVaultFolder(arg1:string,arg2:string):Promise<string>; export function CreateVaultFolder(arg1:string,arg2:string):Promise<string>;
export function CreateWorkspace(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>; export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
export function DisablePlugin(arg1:string):Promise<string>; export function DisablePlugin(arg1:string):Promise<string>;
@ -31,6 +34,8 @@ export function GetCapabilities():Promise<Array<capability.Entry>>;
export function GetContributions():Promise<api.ContributionSummary>; export function GetContributions():Promise<api.ContributionSummary>;
export function GetCurrentWorkspace():Promise<Record<string, any>>;
export function GetCurrentWorkspaceNode():Promise<Record<string, any>>; export function GetCurrentWorkspaceNode():Promise<Record<string, any>>;
export function GetPermissions():Promise<Array<permissions.Entry>>; export function GetPermissions():Promise<Array<permissions.Entry>>;
@ -53,12 +58,16 @@ export function GetWorkbenchOpenedResources():Promise<Array<workbench.OpenedReso
export function GetWorkbenchPreferences():Promise<workbench.Preferences>; export function GetWorkbenchPreferences():Promise<workbench.Preferences>;
export function GetWorkspaceMetadata(arg1:string):Promise<workspace.Metadata|string>;
export function GetWorkspaceTree():Promise<Record<string, any>>; export function GetWorkspaceTree():Promise<Record<string, any>>;
export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Entry>|string>; export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Entry>|string>;
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|string>; export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|string>;
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>; export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>; export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
@ -81,24 +90,46 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise
export function ReloadPlugins():Promise<number|string>; export function ReloadPlugins():Promise<number|string>;
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>; export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function ResetSyncKey():Promise<void>;
export function SelectDirectory():Promise<string>; export function SelectDirectory():Promise<string>;
export function SelectVaultForOpen():Promise<string>; export function SelectVaultForOpen():Promise<string>;
export function SetCurrentVault(arg1:string):Promise<string>; export function SetCurrentVault(arg1:string):Promise<string>;
export function SetCurrentWorkspace(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>; export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>; export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
export function SyncConfigure(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SyncDisconnect():Promise<void>;
export function SyncNow():Promise<Record<string, any>>;
export function SyncSetInterval(arg1:number):Promise<void>;
export function SyncStatus():Promise<api.SyncStatusDTO>;
export function SyncTestConnection(arg1:string,arg2:string,arg3:string):Promise<void>;
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>; export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
export function TrashWorkspace(arg1:string):Promise<workspace.TrashResult|string>;
export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>; export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>;
export function UpdateWorkbenchPreferences(arg1:workbench.Preferences):Promise<string>; export function UpdateWorkbenchPreferences(arg1:workbench.Preferences):Promise<string>;
export function UpdateWorkspaceMetadata(arg1:string,arg2:workspace.MetadataPatch):Promise<workspace.Metadata|string>;
export function WriteFrontendLog(arg1:string,arg2:string):Promise<void>; export function WriteFrontendLog(arg1:string,arg2:string):Promise<void>;
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>; export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;

View File

@ -18,6 +18,10 @@ export function CreateVaultFolder(arg1, arg2) {
return window['go']['api']['App']['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) { export function CreateWorkspaceNode(arg1, arg2, arg3) {
return window['go']['api']['App']['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'](); return window['go']['api']['App']['GetContributions']();
} }
export function GetCurrentWorkspace() {
return window['go']['api']['App']['GetCurrentWorkspace']();
}
export function GetCurrentWorkspaceNode() { export function GetCurrentWorkspaceNode() {
return window['go']['api']['App']['GetCurrentWorkspaceNode'](); return window['go']['api']['App']['GetCurrentWorkspaceNode']();
} }
@ -94,6 +102,10 @@ export function GetWorkbenchPreferences() {
return window['go']['api']['App']['GetWorkbenchPreferences'](); return window['go']['api']['App']['GetWorkbenchPreferences']();
} }
export function GetWorkspaceMetadata(arg1) {
return window['go']['api']['App']['GetWorkspaceMetadata'](arg1);
}
export function GetWorkspaceTree() { export function GetWorkspaceTree() {
return window['go']['api']['App']['GetWorkspaceTree'](); return window['go']['api']['App']['GetWorkspaceTree']();
} }
@ -106,6 +118,10 @@ export function ListVaultFiles(arg1, arg2) {
return window['go']['api']['App']['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) { export function MoveVaultPath(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['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'](); return window['go']['api']['App']['ReloadPlugins']();
} }
export function RenameWorkspace(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspace'](arg1, arg2);
}
export function RenameWorkspaceNode(arg1, arg2) { export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2); return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
} }
export function ResetSyncKey() {
return window['go']['api']['App']['ResetSyncKey']();
}
export function SelectDirectory() { export function SelectDirectory() {
return window['go']['api']['App']['SelectDirectory'](); return window['go']['api']['App']['SelectDirectory']();
} }
@ -166,6 +190,10 @@ export function SetCurrentVault(arg1) {
return window['go']['api']['App']['SetCurrentVault'](arg1); return window['go']['api']['App']['SetCurrentVault'](arg1);
} }
export function SetCurrentWorkspace(arg1) {
return window['go']['api']['App']['SetCurrentWorkspace'](arg1);
}
export function SetCurrentWorkspaceNode(arg1) { export function SetCurrentWorkspaceNode(arg1) {
return window['go']['api']['App']['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); 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) { export function TrashVaultPath(arg1, arg2) {
return window['go']['api']['App']['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) { export function UpdateAppSettings(arg1) {
return window['go']['api']['App']['UpdateAppSettings'](arg1); return window['go']['api']['App']['UpdateAppSettings'](arg1);
} }
@ -186,6 +242,10 @@ export function UpdateWorkbenchPreferences(arg1) {
return window['go']['api']['App']['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) { export function WriteFrontendLog(arg1, arg2) {
return window['go']['api']['App']['WriteFrontendLog'](arg1, arg2); return window['go']['api']['App']['WriteFrontendLog'](arg1, arg2);
} }

View File

@ -207,6 +207,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"];
}
}
} }
export namespace capability { export namespace capability {
@ -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<string, boolean>;
folders?: Record<string, string>;
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<string, boolean>;
folders?: Record<string, string>;
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"];
}
}
}

View File

@ -479,11 +479,81 @@ func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
if len(nodes) == 0 { if len(nodes) == 0 {
t.Fatal("workspace nodes should not be empty") 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") { if !app.capRegistry.Has("verstak/core/workspace/v1") {
t.Fatal("workspace capability should be registered after SetCurrentVault") 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 { func newBridgeTestApp(t *testing.T) *App {
t.Helper() t.Helper()
tmpDir := t.TempDir() tmpDir := t.TempDir()

View File

@ -115,9 +115,10 @@ func (v *Vault) CreateVault(path string) error {
return fmt.Errorf("failed to write vault.json: %w", err) 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) 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) return fmt.Errorf("failed to create workspace: %w", err)
} }

View File

@ -259,44 +259,14 @@ func TestCreateVault_CreatesWorkspace(t *testing.T) {
t.Fatalf("CreateVault: %v", err) t.Fatalf("CreateVault: %v", err)
} }
wsPath := filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json") if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil {
data, err := os.ReadFile(wsPath) t.Fatalf("Workspace folder not found: %v", err)
if err != nil {
t.Fatalf("workspace.json not found: %v", err)
} }
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes", "Overview.md")); err != nil {
var ws struct { t.Fatalf("default workspace overview not found: %v", err)
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 := json.Unmarshal(data, &ws); err != nil { if _, err := os.Stat(filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")); !os.IsNotExist(err) {
t.Fatalf("failed to parse workspace.json: %v", err) t.Fatalf("workspace.json should not be created as workspace source of truth, stat err=%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")
} }
} }
@ -317,27 +287,11 @@ func TestOpenVault_WorkspaceLoads(t *testing.T) {
t.Fatalf("OpenVault: %v", err) t.Fatalf("OpenVault: %v", err)
} }
wsPath := filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json") if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil {
data, err := os.ReadFile(wsPath) t.Fatalf("Workspace folder should still exist after reopen: %v", err)
if err != nil {
t.Fatalf("workspace.json not found after reopen: %v", err)
} }
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")); !os.IsNotExist(err) {
var ws struct { t.Fatalf("OpenVault should not create workspace.json, stat err=%v", err)
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")
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,403 +1,375 @@
package workspace package workspace
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings"
"testing" "testing"
) )
func TestLoad_DefaultRootNode(t *testing.T) { func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
dir := t.TempDir() vaultDir := newVaultDir(t)
vaultDir := filepath.Join(dir, "vault") mustMkdir(t, filepath.Join(vaultDir, "Project"))
verstakDir := filepath.Join(vaultDir, ".verstak") mustMkdir(t, filepath.Join(vaultDir, "Test"))
os.MkdirAll(verstakDir, 0o755) 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) m := NewManager(vaultDir)
if err := m.Load(); err != nil { if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
tree := m.GetTree() workspaces, err := m.ListWorkspaces()
if len(tree.Nodes) != 1 {
t.Fatalf("expected 1 root node, got %d", len(tree.Nodes))
}
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")
}
}
func TestCreateNode_Case(t *testing.T) {
dir := t.TempDir()
vaultDir := filepath.Join(dir, "vault")
verstakDir := filepath.Join(vaultDir, ".verstak")
os.MkdirAll(verstakDir, 0o755)
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
rootID := m.GetTree().Nodes[0].ID
node, err := m.CreateNode(rootID, TypeCase, "Test Case")
if err != nil { 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 node.Title != "Test Case" {
t.Errorf("title: got %q, want %q", node.Title, "Test Case")
}
if node.ParentID != rootID {
t.Errorf("parentID: got %q, want %q", node.ParentID, rootID)
}
if node.Status != StatusActive {
t.Errorf("status: got %s, want %s", node.Status, StatusActive)
}
if node.Path != filepath.Join("My Workspace", "Test Case") {
t.Errorf("path: got %q, want %q", node.Path, filepath.Join("My Workspace", "Test Case"))
}
if _, err := os.Stat(filepath.Join(vaultDir, node.Path)); err != nil {
t.Fatalf("expected workspace folder to exist: %v", err)
} }
// Verify persisted got := workspaceNames(workspaces)
want := []string{"Project", "Test"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("workspaces = %v, want %v", got, want)
}
}
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)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("workspace.json tree should not be source of truth, got %+v", workspaces)
}
}
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 ws.Name != "Project" || ws.RootPath != "Project" {
t.Fatalf("workspace = %+v, want Project root", ws)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); err != nil {
t.Fatalf("workspace folder missing: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil {
t.Fatalf("default template overview missing: %v", err)
}
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() tree := m.GetTree()
if len(tree.Nodes) != 2 { 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) { func TestMoveNodeCompatibilityDoesNotCreateNestedWorkspaceModel(t *testing.T) {
dir := t.TempDir() vaultDir := newVaultDir(t)
vaultDir := filepath.Join(dir, "vault") mustMkdir(t, filepath.Join(vaultDir, "Project"))
os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) mustMkdir(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir) m := NewManager(vaultDir)
if err := m.Load(); err != nil { if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err) 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 func TestMetadataFileShape(t *testing.T) {
first, err := m.CreateNode(rootID, TypeCase, "SameName") 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 { if err != nil {
t.Fatalf("CreateNode first: %v", err) t.Fatalf("read metadata: %v", err)
} }
second, err := m.CreateNode(rootID, TypeCase, "SameName") var raw map[string]interface{}
if err != nil { if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("CreateNode second: %v", err) t.Fatalf("metadata JSON: %v", err)
} }
if raw["workspaceName"] != "Project" {
if first.Path == second.Path { t.Fatalf("workspaceName = %v", raw["workspaceName"])
t.Fatalf("expected unique paths, got %q", first.Path)
} }
if second.Path != filepath.Join("My Workspace", "SameName (2)") { if _, ok := raw["createdFromTemplate"].(map[string]interface{}); !ok {
t.Errorf("second path: got %q, want %q", second.Path, filepath.Join("My Workspace", "SameName (2)")) t.Fatalf("createdFromTemplate missing in raw metadata: %s", data)
} }
} }
func TestCreateNode_InvalidType(t *testing.T) { func newVaultDir(t *testing.T) string {
dir := t.TempDir() t.Helper()
vaultDir := filepath.Join(dir, "vault") vaultDir := filepath.Join(t.TempDir(), "vault")
os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) mustMkdir(t, vaultDir)
mustMkdir(t, filepath.Join(vaultDir, ".verstak", "trash"))
return vaultDir
}
m := NewManager(vaultDir) func mustMkdir(t *testing.T, path string) {
m.Load() t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
_, err := m.CreateNode("", NodeType("note"), "My Note") t.Fatalf("MkdirAll(%s): %v", path, err)
if err == nil {
t.Error("expected error for invalid type 'note'")
} }
} }
func TestCreateNode_EmptyTitle(t *testing.T) { func mustWrite(t *testing.T, path, content string) {
dir := t.TempDir() t.Helper()
vaultDir := filepath.Join(dir, "vault") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err)
m := NewManager(vaultDir)
m.Load()
_, err := m.CreateNode("", TypeCase, "")
if err == nil {
t.Error("expected error for empty title")
} }
_, err = m.CreateNode("", TypeCase, " ") if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
if err == nil { t.Fatalf("WriteFile(%s): %v", path, err)
t.Error("expected error for whitespace-only title")
} }
} }
func TestRenameNode(t *testing.T) { func workspaceNames(workspaces []Workspace) []string {
dir := t.TempDir() names := make([]string, len(workspaces))
vaultDir := filepath.Join(dir, "vault") for i, ws := range workspaces {
os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) names[i] = ws.Name
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")
} }
return names
} }