Compare commits
4 Commits
5c979174f1
...
0ac473d720
| Author | SHA1 | Date |
|---|---|---|
|
|
0ac473d720 | |
|
|
4de5a74a55 | |
|
|
db67c370ab | |
|
|
ed69746332 |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,23 @@ export function createPluginAPI(pluginId) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
backend: {
|
||||||
|
call: async function(method, ...args) {
|
||||||
|
assertActive('backend.call(' + method + ')');
|
||||||
|
try {
|
||||||
|
const App = window['go']?.['api']?.['App'];
|
||||||
|
if (!App || typeof App[method] !== 'function') {
|
||||||
|
throw new Error('Backend method not found: ' + method);
|
||||||
|
}
|
||||||
|
const result = await App[method](...args);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const message = e && e.message ? e.message : String(e);
|
||||||
|
throw new Error('[plugin:' + pluginId + '] backend.call(' + method + ') failed: ' + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
workbench: {
|
workbench: {
|
||||||
openResource: async function(request) {
|
openResource: async function(request) {
|
||||||
assertActive('workbench.openResource');
|
assertActive('workbench.openResource');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script context="module">
|
<script context="module">
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
const activeWorkspaceNodeId = writable('');
|
const activeWorkspaceId = writable('');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -9,40 +9,58 @@
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
|
||||||
export let nodes = [];
|
|
||||||
export let node = null;
|
|
||||||
export let currentNodeId = '';
|
|
||||||
export let expandedNodes = {};
|
|
||||||
export let depth = 0;
|
|
||||||
|
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let localError = '';
|
let localError = '';
|
||||||
|
let workspaces = [];
|
||||||
|
let currentWorkspaceId = '';
|
||||||
let showCreate = false;
|
let showCreate = false;
|
||||||
let newNodeTitle = '';
|
let newWorkspaceName = '';
|
||||||
let newNodeParentId = '';
|
|
||||||
let newNodeType = 'case';
|
|
||||||
let creating = false;
|
let creating = false;
|
||||||
|
let renamingId = '';
|
||||||
|
let renameValue = '';
|
||||||
|
let busyId = '';
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(loadWorkspaces);
|
||||||
if (depth === 0) {
|
|
||||||
await loadTree();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadTree() {
|
function resultOrError(response, fallbackValue) {
|
||||||
|
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsName(workspace) {
|
||||||
|
return String(workspace?.name || workspace?.rootPath || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNode(workspace, order) {
|
||||||
|
const name = wsName(workspace);
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
type: 'space',
|
||||||
|
title: name,
|
||||||
|
name,
|
||||||
|
rootPath: workspace.rootPath || name,
|
||||||
|
status: 'active',
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodesForEvent() {
|
||||||
|
return workspaces.map(asNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkspaces() {
|
||||||
loading = true;
|
loading = true;
|
||||||
localError = '';
|
localError = '';
|
||||||
try {
|
try {
|
||||||
const result = await App.GetWorkspaceTree();
|
const [list, err] = resultOrError(await App.ListWorkspaces(), []);
|
||||||
if (result.status === 'not initialized') {
|
if (err) {
|
||||||
nodes = [];
|
localError = err;
|
||||||
currentNodeId = '';
|
workspaces = [];
|
||||||
} else {
|
} else {
|
||||||
nodes = result.nodes || [];
|
workspaces = list || [];
|
||||||
currentNodeId = result.currentNodeId || '';
|
if (!currentWorkspaceId || !workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
|
||||||
activeWorkspaceNodeId.set(currentNodeId);
|
currentWorkspaceId = wsName(workspaces[0] || {});
|
||||||
const root = nodes.find(n => !n.parentId);
|
}
|
||||||
if (root) expandedNodes[root.id] = true;
|
activeWorkspaceId.set(currentWorkspaceId);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
localError = String(e);
|
localError = String(e);
|
||||||
|
|
@ -50,143 +68,176 @@
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function childrenOf(parentId) {
|
async function selectWorkspace(workspace) {
|
||||||
return nodes.filter(n => n.parentId === parentId).sort((a, b) => a.order - b.order);
|
const id = wsName(workspace);
|
||||||
}
|
const err = await App.SetCurrentWorkspace(id);
|
||||||
|
if (err) {
|
||||||
function roots() {
|
localError = err;
|
||||||
return nodes.filter(n => !n.parentId).sort((a, b) => a.order - b.order);
|
return;
|
||||||
}
|
}
|
||||||
|
currentWorkspaceId = id;
|
||||||
function toggle(id) {
|
activeWorkspaceId.set(id);
|
||||||
expandedNodes[id] = !expandedNodes[id];
|
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
|
||||||
expandedNodes = expandedNodes;
|
detail: { workspaceName: id, nodes: nodesForEvent() }
|
||||||
}
|
|
||||||
|
|
||||||
function hasKids(id) {
|
|
||||||
return nodes.some(n => n.parentId === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function iconName(type) {
|
|
||||||
if (type === 'space') return 'space';
|
|
||||||
if (type === 'case') return 'case';
|
|
||||||
if (type === 'folder') return 'folder';
|
|
||||||
return 'dot';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectNode(id) {
|
|
||||||
const err = await App.SetCurrentWorkspaceNode(id);
|
|
||||||
if (err) { localError = err; return; }
|
|
||||||
currentNodeId = id;
|
|
||||||
activeWorkspaceNodeId.set(id);
|
|
||||||
window.dispatchEvent(new CustomEvent('verstak:workspace-node-selected', {
|
|
||||||
detail: { nodeId: id, nodes: nodes }
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate(parentId, type) {
|
|
||||||
newNodeParentId = parentId;
|
|
||||||
newNodeType = type;
|
|
||||||
newNodeTitle = '';
|
|
||||||
showCreate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doCreate() {
|
async function doCreate() {
|
||||||
if (!newNodeTitle.trim()) return;
|
const name = newWorkspaceName.trim();
|
||||||
|
if (!name) return;
|
||||||
creating = true;
|
creating = true;
|
||||||
const res = await App.CreateWorkspaceNode(newNodeParentId, newNodeType, newNodeTitle.trim());
|
localError = '';
|
||||||
if (res.error) { localError = res.error; creating = false; return; }
|
const [, err] = resultOrError(await App.CreateWorkspace(name, 'default'), null);
|
||||||
|
if (err) {
|
||||||
|
localError = err;
|
||||||
|
creating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
showCreate = false;
|
showCreate = false;
|
||||||
|
newWorkspaceName = '';
|
||||||
creating = false;
|
creating = false;
|
||||||
expandedNodes[newNodeParentId] = true;
|
await loadWorkspaces();
|
||||||
expandedNodes = expandedNodes;
|
const created = workspaces.find((ws) => wsName(ws) === name);
|
||||||
await loadTree();
|
if (created) await selectWorkspace(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCreate() {
|
function startRename(workspace) {
|
||||||
showCreate = false;
|
renamingId = wsName(workspace);
|
||||||
newNodeTitle = '';
|
renameValue = renamingId;
|
||||||
|
localError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
renamingId = '';
|
||||||
|
renameValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitRename(workspace) {
|
||||||
|
const oldName = wsName(workspace);
|
||||||
|
const newName = renameValue.trim();
|
||||||
|
if (!newName || newName === oldName) {
|
||||||
|
cancelRename();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busyId = oldName;
|
||||||
|
const err = await App.RenameWorkspace(oldName, newName);
|
||||||
|
if (err) {
|
||||||
|
localError = err;
|
||||||
|
busyId = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renamingId = '';
|
||||||
|
renameValue = '';
|
||||||
|
busyId = '';
|
||||||
|
currentWorkspaceId = newName;
|
||||||
|
await loadWorkspaces();
|
||||||
|
const renamed = workspaces.find((ws) => wsName(ws) === newName);
|
||||||
|
if (renamed) await selectWorkspace(renamed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trashWorkspace(workspace) {
|
||||||
|
const name = wsName(workspace);
|
||||||
|
busyId = name;
|
||||||
|
const [, err] = resultOrError(await App.TrashWorkspace(name), null);
|
||||||
|
if (err) {
|
||||||
|
localError = err;
|
||||||
|
busyId = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentWorkspaceId === name) currentWorkspaceId = '';
|
||||||
|
busyId = '';
|
||||||
|
await loadWorkspaces();
|
||||||
|
if (currentWorkspaceId) {
|
||||||
|
const selected = workspaces.find((ws) => wsName(ws) === currentWorkspaceId);
|
||||||
|
if (selected) await selectWorkspace(selected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if depth === 0}
|
<div class="wt">
|
||||||
<div class="wt">
|
<div class="wt-header">
|
||||||
<div class="wt-header">
|
<span class="wt-title">Workspaces</span>
|
||||||
<span class="wt-title">Workspace</span>
|
<button class="wt-btn" on:click={() => { showCreate = true; newWorkspaceName = ''; }} title="New workspace" type="button">+</button>
|
||||||
<button class="wt-btn" on:click={() => openCreate('', 'space')} title="New Space" type="button">+</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="wt-loading">Loading...</div>
|
<div class="wt-loading">Loading...</div>
|
||||||
{:else if localError}
|
{:else if localError}
|
||||||
<div class="wt-error">{localError}</div>
|
<div class="wt-error">{localError}</div>
|
||||||
{:else}
|
{/if}
|
||||||
{#each roots() as node (node.id)}
|
|
||||||
<svelte:self {node} {nodes} {currentNodeId} {expandedNodes} depth={1} {toggle} {hasKids} {selectNode} {openCreate} />
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showCreate}
|
<div class="wt-list">
|
||||||
<div class="wt-create">
|
{#each workspaces as workspace (wsName(workspace))}
|
||||||
<div class="wt-create-header">
|
{@const id = wsName(workspace)}
|
||||||
<span>New {newNodeType}</span>
|
<div class="wt-node" class:selected={id === $activeWorkspaceId}>
|
||||||
<button class="wt-btn" on:click={cancelCreate} type="button">x</button>
|
<div class="wt-row">
|
||||||
</div>
|
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
|
||||||
<input type="text" bind:value={newNodeTitle} placeholder="Name..." disabled={creating} />
|
{#if renamingId === id}
|
||||||
<div class="wt-create-actions">
|
<input
|
||||||
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || !newNodeTitle.trim()}>{creating ? '...' : 'Create'}</button>
|
class="wt-rename"
|
||||||
<button class="wt-btn" on:click={cancelCreate} type="button" disabled={creating}>Cancel</button>
|
bind:value={renameValue}
|
||||||
|
disabled={busyId === id}
|
||||||
|
on:keydown={(e) => {
|
||||||
|
if (e.key === 'Enter') commitRename(workspace);
|
||||||
|
if (e.key === 'Escape') cancelRename();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title="Save rename" type="button" disabled={busyId === id}>OK</button>
|
||||||
|
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title="Cancel rename" type="button" disabled={busyId === id}>x</button>
|
||||||
|
{:else}
|
||||||
|
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
|
||||||
|
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title="Rename workspace" type="button" disabled={busyId === id}>
|
||||||
|
<Icon name="edit" size={12} />
|
||||||
|
</button>
|
||||||
|
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title="Trash workspace" type="button" disabled={busyId === id}>
|
||||||
|
<Icon name="trash" size={12} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<div class="wt-node" class:selected={node.id === $activeWorkspaceNodeId} class:archived={node.status === 'archived'} class:sleeping={node.status === 'sleeping'}>
|
{#if showCreate}
|
||||||
<div class="wt-row" style="padding-left: {depth * 1.0 + 0.4}rem;">
|
<div class="wt-create">
|
||||||
{#if hasKids(node.id)}
|
<div class="wt-create-header">
|
||||||
<button class="wt-expand" on:click={() => toggle(node.id)} type="button" aria-label={expandedNodes[node.id] ? 'Collapse' : 'Expand'}>
|
<span>New workspace</span>
|
||||||
<Icon name={expandedNodes[node.id] ? 'chevronDown' : 'chevronRight'} size={12} class="wt-expand-icon" />
|
<button class="wt-btn" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button">x</button>
|
||||||
</button>
|
</div>
|
||||||
{:else}
|
<input type="text" bind:value={newWorkspaceName} placeholder="Name..." disabled={creating} />
|
||||||
<span class="wt-expand-spacer"></span>
|
<div class="wt-create-actions">
|
||||||
{/if}
|
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || !newWorkspaceName.trim()}>{creating ? '...' : 'Create'}</button>
|
||||||
<span class="wt-icon"><Icon name={iconName(node.type)} size={13} class="wt-node-icon" /></span>
|
<button class="wt-btn" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button" disabled={creating}>Cancel</button>
|
||||||
<button class="wt-label" on:click={() => selectNode(node.id)} type="button">{node.title}</button>
|
</div>
|
||||||
{#if node.type !== 'case'}
|
|
||||||
<button class="wt-btn wt-btn-small" on:click={() => openCreate(node.id, 'case')} title="Add child" type="button">+</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{#if expandedNodes[node.id]}
|
{/if}
|
||||||
{#each childrenOf(node.id) as child (child.id)}
|
</div>
|
||||||
<svelte:self node={child} {nodes} {currentNodeId} {expandedNodes} depth={depth + 1} {toggle} {hasKids} {selectNode} {openCreate} />
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
|
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
|
||||||
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.6rem; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
|
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.6rem; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
|
||||||
.wt-title { color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
.wt-title { color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||||||
|
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0; }
|
||||||
.wt-btn { min-height: 0; background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
.wt-btn { min-height: 0; background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
||||||
.wt-btn:hover { color: #4ecca3; background: rgba(78,204,163,0.1); }
|
.wt-btn:hover:not(:disabled) { color: #4ecca3; background: rgba(78,204,163,0.1); }
|
||||||
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
|
.wt-btn-small { font-size: 0.68rem; opacity: 0; }
|
||||||
|
.wt-always { opacity: 1; }
|
||||||
.wt-row:hover .wt-btn-small { opacity: 1; }
|
.wt-row:hover .wt-btn-small { opacity: 1; }
|
||||||
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: #666; }
|
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: #666; }
|
||||||
.wt-error { color: #e94560; }
|
.wt-error { color: #e94560; }
|
||||||
.wt-node { }
|
.wt-row { display: flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.45rem; min-height: 1.7rem; }
|
||||||
.wt-row { display: flex; align-items: center; gap: 0.2rem; padding: 0.15rem 0; }
|
|
||||||
.wt-row:hover { background: rgba(15,52,96,0.4); }
|
.wt-row:hover { background: rgba(15,52,96,0.4); }
|
||||||
.wt-node.selected > .wt-row { background: rgba(78,204,163,0.1); }
|
.wt-node.selected > .wt-row { background: rgba(78,204,163,0.1); }
|
||||||
.wt-expand { width: 1rem; height: 1rem; min-height: 0; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #666; background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; }
|
|
||||||
.wt-expand:hover { color: #e0e0f0; }
|
|
||||||
.wt-expand-spacer { width: 1rem; flex-shrink: 0; }
|
|
||||||
.wt-icon { width: 0.9rem; height: 0.9rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: #a0a0b8; }
|
.wt-icon { width: 0.9rem; height: 0.9rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: #a0a0b8; }
|
||||||
:global(.wt-node-icon), :global(.wt-expand-icon) { display: block; }
|
:global(.wt-node-icon) { display: block; }
|
||||||
.wt-label { flex: 1; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.wt-label:hover { color: #4ecca3; }
|
.wt-label:hover { color: #4ecca3; }
|
||||||
.wt-node.archived .wt-label { text-decoration: line-through; opacity: 0.5; }
|
.wt-icon-btn { width: 1.25rem; height: 1.25rem; min-height: 0; padding: 0; border: none; background: transparent; color: #666; opacity: 0; flex-shrink: 0; cursor: pointer; border-radius: 3px; }
|
||||||
.wt-node.sleeping .wt-label { opacity: 0.6; }
|
.wt-row:hover .wt-icon-btn { opacity: 1; }
|
||||||
|
.wt-icon-btn:hover:not(:disabled) { color: #4ecca3; background: rgba(78,204,163,0.1); }
|
||||||
|
.wt-icon-btn.danger:hover:not(:disabled) { color: #e94560; background: rgba(233,69,96,0.12); }
|
||||||
|
.wt-rename { flex: 1; min-width: 0; background: #0f3460; border: 1px solid #1a3a5c; color: #e0e0f0; padding: 0.2rem 0.35rem; border-radius: 4px; font-size: 0.78rem; }
|
||||||
|
.wt-rename:focus { outline: none; border-color: #4ecca3; }
|
||||||
.wt-create { position: absolute; bottom: 0; left: 0; right: 0; background: #16213e; border-top: 1px solid #0f3460; padding: 0.6rem; z-index: 10; }
|
.wt-create { position: absolute; bottom: 0; left: 0; right: 0; background: #16213e; border-top: 1px solid #0f3460; padding: 0.6rem; z-index: 10; }
|
||||||
.wt-create-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; }
|
.wt-create-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; }
|
||||||
.wt-create input { width: 100%; background: #0f3460; border: 1px solid #1a3a5c; color: #e0e0f0; padding: 0.35rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-bottom: 0.4rem; box-sizing: border-box; }
|
.wt-create input { width: 100%; background: #0f3460; border: 1px solid #1a3a5c; color: #e0e0f0; padding: 0.35rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-bottom: 0.4rem; box-sizing: border-box; }
|
||||||
|
|
@ -194,5 +245,5 @@
|
||||||
.wt-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
|
.wt-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
|
||||||
.wt-btn-primary { background: #4ecca3; color: #1a1a2e; border: none; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; font-weight: 600; }
|
.wt-btn-primary { background: #4ecca3; color: #1a1a2e; border: none; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; font-weight: 600; }
|
||||||
.wt-btn-primary:hover:not(:disabled) { background: #3dbb92; }
|
.wt-btn-primary:hover:not(:disabled) { background: #3dbb92; }
|
||||||
.wt-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
.wt-btn-primary:disabled, .wt-btn:disabled, .wt-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
'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' }],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||||
|
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench"
|
coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||||
|
|
@ -43,6 +44,7 @@ type App struct {
|
||||||
pluginState *pluginstate.Manager
|
pluginState *pluginstate.Manager
|
||||||
workbench *coreworkbench.Router
|
workbench *coreworkbench.Router
|
||||||
workspace *workspace.Manager
|
workspace *workspace.Manager
|
||||||
|
syncSvc *syncsvc.Service
|
||||||
debug bool
|
debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,6 +61,7 @@ func NewApp(
|
||||||
appSettingsMgr *appsettings.Manager,
|
appSettingsMgr *appsettings.Manager,
|
||||||
pluginStateMgr *pluginstate.Manager,
|
pluginStateMgr *pluginstate.Manager,
|
||||||
workspaceMgr *workspace.Manager,
|
workspaceMgr *workspace.Manager,
|
||||||
|
syncService *syncsvc.Service,
|
||||||
debugEnabled bool,
|
debugEnabled bool,
|
||||||
) *App {
|
) *App {
|
||||||
return &App{
|
return &App{
|
||||||
|
|
@ -74,6 +77,7 @@ func NewApp(
|
||||||
pluginState: pluginStateMgr,
|
pluginState: pluginStateMgr,
|
||||||
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
||||||
workspace: workspaceMgr,
|
workspace: workspaceMgr,
|
||||||
|
syncSvc: syncService,
|
||||||
debug: debugEnabled,
|
debug: debugEnabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -950,7 +954,105 @@ func (a *App) SetCurrentVault(path string) string {
|
||||||
|
|
||||||
// ─── Workspace API ─────────────────────────────────────────
|
// ─── Workspace API ─────────────────────────────────────────
|
||||||
|
|
||||||
// GetWorkspaceTree returns the full workspace tree.
|
// ListWorkspaces returns top-level physical workspace folders.
|
||||||
|
func (a *App) ListWorkspaces() ([]workspace.Workspace, string) {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return nil, "workspace not initialized"
|
||||||
|
}
|
||||||
|
workspaces, err := a.workspace.ListWorkspaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err.Error()
|
||||||
|
}
|
||||||
|
return workspaces, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWorkspace creates a top-level physical workspace folder.
|
||||||
|
func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return workspace.Workspace{}, "workspace not initialized"
|
||||||
|
}
|
||||||
|
ws, err := a.workspace.CreateWorkspace(name, templateID)
|
||||||
|
if err != nil {
|
||||||
|
return workspace.Workspace{}, err.Error()
|
||||||
|
}
|
||||||
|
return ws, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameWorkspace physically renames a top-level workspace folder.
|
||||||
|
func (a *App) RenameWorkspace(oldName, newName string) string {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return "workspace not initialized"
|
||||||
|
}
|
||||||
|
if err := a.workspace.RenameWorkspace(oldName, newName); err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrashWorkspace moves a top-level workspace folder to internal trash.
|
||||||
|
func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return workspace.TrashResult{}, "workspace not initialized"
|
||||||
|
}
|
||||||
|
result, err := a.workspace.TrashWorkspace(name)
|
||||||
|
if err != nil {
|
||||||
|
return workspace.TrashResult{}, err.Error()
|
||||||
|
}
|
||||||
|
return result, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorkspaceMetadata returns metadata or a generic fallback for a workspace.
|
||||||
|
func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return workspace.Metadata{}, "workspace not initialized"
|
||||||
|
}
|
||||||
|
meta, err := a.workspace.GetWorkspaceMetadata(name)
|
||||||
|
if err != nil {
|
||||||
|
return workspace.Metadata{}, err.Error()
|
||||||
|
}
|
||||||
|
return meta, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateWorkspaceMetadata merges metadata for an existing workspace.
|
||||||
|
func (a *App) UpdateWorkspaceMetadata(name string, patch workspace.MetadataPatch) (workspace.Metadata, string) {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return workspace.Metadata{}, "workspace not initialized"
|
||||||
|
}
|
||||||
|
meta, err := a.workspace.UpdateWorkspaceMetadata(name, patch)
|
||||||
|
if err != nil {
|
||||||
|
return workspace.Metadata{}, err.Error()
|
||||||
|
}
|
||||||
|
return meta, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentWorkspace returns the currently selected top-level workspace.
|
||||||
|
func (a *App) GetCurrentWorkspace() map[string]interface{} {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return map[string]interface{}{"status": "not initialized"}
|
||||||
|
}
|
||||||
|
node, err := a.workspace.GetCurrentNode()
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"name": node.Name,
|
||||||
|
"rootPath": node.RootPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCurrentWorkspace stores the selected top-level workspace name as UI state.
|
||||||
|
func (a *App) SetCurrentWorkspace(name string) string {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return "workspace not initialized"
|
||||||
|
}
|
||||||
|
if err := a.workspace.SetCurrentNode(name); err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer ListWorkspaces.
|
||||||
func (a *App) GetWorkspaceTree() map[string]interface{} {
|
func (a *App) GetWorkspaceTree() map[string]interface{} {
|
||||||
if a.workspace == nil || !a.workspace.IsInitialized() {
|
if a.workspace == nil || !a.workspace.IsInitialized() {
|
||||||
return map[string]interface{}{"status": "not initialized"}
|
return map[string]interface{}{"status": "not initialized"}
|
||||||
|
|
@ -964,7 +1066,8 @@ func (a *App) GetWorkspaceTree() map[string]interface{} {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateWorkspaceNode creates a new workspace node.
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer CreateWorkspace.
|
||||||
func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]interface{} {
|
func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]interface{} {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return map[string]interface{}{"error": "workspace not initialized"}
|
return map[string]interface{}{"error": "workspace not initialized"}
|
||||||
|
|
@ -978,6 +1081,8 @@ func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]i
|
||||||
"parentId": node.ParentID,
|
"parentId": node.ParentID,
|
||||||
"type": string(node.Type),
|
"type": string(node.Type),
|
||||||
"title": node.Title,
|
"title": node.Title,
|
||||||
|
"name": node.Name,
|
||||||
|
"rootPath": node.RootPath,
|
||||||
"status": string(node.Status),
|
"status": string(node.Status),
|
||||||
"order": node.Order,
|
"order": node.Order,
|
||||||
"createdAt": node.CreatedAt,
|
"createdAt": node.CreatedAt,
|
||||||
|
|
@ -985,7 +1090,8 @@ func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenameWorkspaceNode renames a workspace node.
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer RenameWorkspace.
|
||||||
func (a *App) RenameWorkspaceNode(id, title string) string {
|
func (a *App) RenameWorkspaceNode(id, title string) string {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return "workspace not initialized"
|
return "workspace not initialized"
|
||||||
|
|
@ -996,7 +1102,8 @@ func (a *App) RenameWorkspaceNode(id, title string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveWorkspaceNode moves a node to a new parent.
|
// Deprecated: compatibility wrapper retained only to reject old nested tree
|
||||||
|
// moves. The corrected workspace model is top-level folders only.
|
||||||
func (a *App) MoveWorkspaceNode(id, newParentID string) string {
|
func (a *App) MoveWorkspaceNode(id, newParentID string) string {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return "workspace not initialized"
|
return "workspace not initialized"
|
||||||
|
|
@ -1007,7 +1114,8 @@ func (a *App) MoveWorkspaceNode(id, newParentID string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveWorkspaceNode archives a workspace node.
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer TrashWorkspace.
|
||||||
func (a *App) ArchiveWorkspaceNode(id string) string {
|
func (a *App) ArchiveWorkspaceNode(id string) string {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return "workspace not initialized"
|
return "workspace not initialized"
|
||||||
|
|
@ -1018,7 +1126,8 @@ func (a *App) ArchiveWorkspaceNode(id string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentWorkspaceNode returns the currently selected node.
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer GetCurrentWorkspace.
|
||||||
func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
|
func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return map[string]interface{}{"status": "not initialized"}
|
return map[string]interface{}{"status": "not initialized"}
|
||||||
|
|
@ -1028,14 +1137,17 @@ func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
|
||||||
return map[string]interface{}{"error": err.Error()}
|
return map[string]interface{}{"error": err.Error()}
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": node.ID,
|
"id": node.ID,
|
||||||
"type": string(node.Type),
|
"type": string(node.Type),
|
||||||
"title": node.Title,
|
"title": node.Title,
|
||||||
"status": string(node.Status),
|
"name": node.Name,
|
||||||
|
"rootPath": node.RootPath,
|
||||||
|
"status": string(node.Status),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCurrentWorkspaceNode sets the currently selected node.
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer SetCurrentWorkspace.
|
||||||
func (a *App) SetCurrentWorkspaceNode(id string) string {
|
func (a *App) SetCurrentWorkspaceNode(id string) string {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return "workspace not initialized"
|
return "workspace not initialized"
|
||||||
|
|
@ -1201,3 +1313,318 @@ func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string)
|
||||||
}
|
}
|
||||||
return string(data), ""
|
return string(data), ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sync API ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *App) requireVault() error {
|
||||||
|
if a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
|
||||||
|
return fmt.Errorf("vault not open")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) vaultPath() string {
|
||||||
|
if a.vault == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return a.vault.GetVaultPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStatusDTO holds sync status information for the frontend.
|
||||||
|
type SyncStatusDTO struct {
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
ServerURL string `json:"serverUrl"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
DeviceName string `json:"deviceName"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
TokenStored bool `json:"tokenStored"`
|
||||||
|
UnpushedOps int `json:"unpushedOps"`
|
||||||
|
LastSyncAt string `json:"lastSyncAt"`
|
||||||
|
SyncInterval int `json:"syncInterval"`
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
StatusLabel string `json:"statusLabel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStatus returns the current sync status.
|
||||||
|
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||||
|
if a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
|
||||||
|
return &SyncStatusDTO{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultPath := a.vaultPath()
|
||||||
|
if a.syncSvc == nil {
|
||||||
|
return &SyncStatusDTO{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
serverURL, apiKey, _, lastSyncAt, err := a.syncSvc.GetState()
|
||||||
|
if err != nil {
|
||||||
|
return &SyncStatusDTO{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||||
|
|
||||||
|
dto := &SyncStatusDTO{
|
||||||
|
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
|
||||||
|
ServerURL: serverURL,
|
||||||
|
LastSyncAt: lastSyncAt,
|
||||||
|
UnpushedOps: 0,
|
||||||
|
TokenStored: deviceToken != "",
|
||||||
|
SyncInterval: cfg.Sync.SyncInterval,
|
||||||
|
LastError: cfg.Sync.LastError,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Sync.DeviceID != "" {
|
||||||
|
dto.DeviceID = cfg.Sync.DeviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
unpushed, _ := a.syncSvc.GetUnpushedOps()
|
||||||
|
dto.UnpushedOps = len(unpushed)
|
||||||
|
|
||||||
|
if deviceToken != "" {
|
||||||
|
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||||
|
client.DeviceToken = deviceToken
|
||||||
|
if cfg.Sync.DeviceID != "" {
|
||||||
|
client.DeviceID = cfg.Sync.DeviceID
|
||||||
|
}
|
||||||
|
if info, err := client.GetMe(); err == nil {
|
||||||
|
dto.DeviceName = info.DeviceName
|
||||||
|
dto.DeviceID = info.DeviceID
|
||||||
|
dto.Connected = true
|
||||||
|
if info.RevokedAt != "" {
|
||||||
|
dto.Revoked = true
|
||||||
|
dto.Connected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case dto.Revoked:
|
||||||
|
dto.StatusLabel = "revoked"
|
||||||
|
case dto.Connected:
|
||||||
|
dto.StatusLabel = "connected"
|
||||||
|
case dto.Configured:
|
||||||
|
dto.StatusLabel = "disconnected"
|
||||||
|
default:
|
||||||
|
dto.StatusLabel = "disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Sync.LastSyncAt != lastSyncAt || cfg.Sync.LastStatus != dto.StatusLabel {
|
||||||
|
cfg.Sync.LastSyncAt = lastSyncAt
|
||||||
|
cfg.Sync.LastStatus = dto.StatusLabel
|
||||||
|
_ = a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncConfigure pairs the device with a sync server.
|
||||||
|
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vaultPath := a.vaultPath()
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = "unknown"
|
||||||
|
}
|
||||||
|
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||||
|
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pair: %w", err)
|
||||||
|
}
|
||||||
|
if err := syncsvc.SaveDeviceToken(vaultPath, deviceToken); err != nil {
|
||||||
|
return fmt.Errorf("save token: %w", err)
|
||||||
|
}
|
||||||
|
if err := a.syncSvc.SetState(serverURL, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
cfg.Sync.Enabled = true
|
||||||
|
cfg.Sync.ServerURL = serverURL
|
||||||
|
cfg.Sync.DeviceID = deviceID
|
||||||
|
cfg.Sync.DeviceName = hostname
|
||||||
|
cfg.Sync.LastStatus = "connected"
|
||||||
|
_ = a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncDisconnect disconnects from the sync server and revokes the device token.
|
||||||
|
func (a *App) SyncDisconnect() error {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vaultPath := a.vaultPath()
|
||||||
|
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
|
||||||
|
if deviceToken != "" {
|
||||||
|
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", vaultPath)
|
||||||
|
client.DeviceToken = deviceToken
|
||||||
|
_ = client.RevokeCurrent()
|
||||||
|
}
|
||||||
|
_ = syncsvc.RemoveDeviceToken(vaultPath)
|
||||||
|
|
||||||
|
cfg.Sync.Enabled = false
|
||||||
|
cfg.Sync.ServerURL = ""
|
||||||
|
cfg.Sync.DeviceID = ""
|
||||||
|
cfg.Sync.DeviceName = ""
|
||||||
|
cfg.Sync.LastStatus = "disabled"
|
||||||
|
cfg.Sync.LastError = ""
|
||||||
|
if err := a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.syncSvc.SetState("", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncTestConnection tests the connection to a sync server with the given credentials.
|
||||||
|
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||||
|
vaultPath := a.vaultPath()
|
||||||
|
if vaultPath == "" {
|
||||||
|
vaultPath = "/tmp"
|
||||||
|
}
|
||||||
|
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||||
|
return client.TestAuth(serverURL, username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncSetInterval sets the auto-sync interval in minutes.
|
||||||
|
func (a *App) SyncSetInterval(minutes int) error {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
cfg.Sync.SyncInterval = minutes
|
||||||
|
if cfg.Sync.DeviceID == "" && a.syncSvc != nil {
|
||||||
|
cfg.Sync.DeviceID = a.syncSvc.GetDeviceID()
|
||||||
|
}
|
||||||
|
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncNow triggers an immediate sync cycle (push local ops, pull remote ops).
|
||||||
|
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
vaultPath := a.vaultPath()
|
||||||
|
|
||||||
|
serverURL, apiKey, lastPullSeq, _, err := a.syncSvc.GetState()
|
||||||
|
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||||
|
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
|
||||||
|
return nil, fmt.Errorf("sync not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID := ""
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
if cfg.Sync.DeviceID != "" {
|
||||||
|
deviceID = cfg.Sync.DeviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
client := syncsvc.NewClient(serverURL, apiKey, deviceID, vaultPath)
|
||||||
|
client.DeviceToken = deviceToken
|
||||||
|
|
||||||
|
unpushed, err := a.syncSvc.GetUnpushedOps()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get ops: %w", err)
|
||||||
|
}
|
||||||
|
for i := range unpushed {
|
||||||
|
unpushed[i].LastSeenServerSeq = lastPullSeq
|
||||||
|
}
|
||||||
|
pushResult := &syncsvc.PushResponse{}
|
||||||
|
if len(unpushed) > 0 {
|
||||||
|
pushResult, err = client.Push(unpushed)
|
||||||
|
if err != nil {
|
||||||
|
_ = a.updateSyncError(fmt.Sprintf("push: %v", err))
|
||||||
|
return nil, fmt.Errorf("push: %w", err)
|
||||||
|
}
|
||||||
|
if err := a.syncSvc.MarkPushed(pushResult.Accepted); err != nil {
|
||||||
|
return nil, fmt.Errorf("mark pushed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pullResult, err := client.Pull(lastPullSeq)
|
||||||
|
if err != nil {
|
||||||
|
_ = a.updateSyncError(fmt.Sprintf("pull: %v", err))
|
||||||
|
return nil, fmt.Errorf("pull: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var applyErrors []string
|
||||||
|
for _, op := range pullResult.Ops {
|
||||||
|
if err := a.applyRemoteOp(op); err != nil {
|
||||||
|
applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err))
|
||||||
|
}
|
||||||
|
_ = a.syncSvc.RecordRemoteOp(op)
|
||||||
|
}
|
||||||
|
if len(pullResult.Ops) > 0 {
|
||||||
|
opIDs := make([]string, len(pullResult.Ops))
|
||||||
|
for i, op := range pullResult.Ops {
|
||||||
|
opIDs[i] = op.OpID
|
||||||
|
}
|
||||||
|
_ = a.syncSvc.MarkApplied(opIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pushResult.Conflicts) > 0 {
|
||||||
|
log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts))
|
||||||
|
for _, c := range pushResult.Conflicts {
|
||||||
|
log.Printf("[sync] conflict: op=%v entity=%v/%v",
|
||||||
|
c["op_id"], c["entity_type"], c["entity_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pullResult.ServerSequence > lastPullSeq {
|
||||||
|
_ = a.syncSvc.SetLastPullSeq(pullResult.ServerSequence)
|
||||||
|
}
|
||||||
|
_ = a.syncSvc.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
a.updateSyncSuccess(now)
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"pushed": len(pushResult.Accepted),
|
||||||
|
"pulled": len(pullResult.Ops),
|
||||||
|
"serverSequence": pullResult.ServerSequence,
|
||||||
|
}
|
||||||
|
if len(applyErrors) > 0 {
|
||||||
|
result["applyErrors"] = applyErrors
|
||||||
|
}
|
||||||
|
if len(pushResult.Conflicts) > 0 {
|
||||||
|
result["conflicts"] = pushResult.Conflicts
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetSyncKey clears the device token and resets sync state.
|
||||||
|
func (a *App) ResetSyncKey() error {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = syncsvc.RemoveDeviceToken(a.vaultPath())
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
cfg.Sync.LastStatus = "disabled"
|
||||||
|
cfg.Sync.LastError = ""
|
||||||
|
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateSyncError(errMsg string) error {
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
cfg.Sync.LastError = errMsg
|
||||||
|
cfg.Sync.LastStatus = "error"
|
||||||
|
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateSyncSuccess(lastSyncAt string) error {
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
cfg.Sync.LastError = ""
|
||||||
|
cfg.Sync.LastStatus = "connected"
|
||||||
|
cfg.Sync.LastSyncAt = lastSyncAt
|
||||||
|
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) applyRemoteOp(op syncsvc.Op) error {
|
||||||
|
if a.debug {
|
||||||
|
log.Printf("[sync] applyRemoteOp: type=%s entity=%s/%s", op.OpType, op.EntityType, op.EntityID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type Config struct {
|
||||||
DevMode bool `json:"devMode"`
|
DevMode bool `json:"devMode"`
|
||||||
UserPluginsDir string `json:"userPluginsDir"`
|
UserPluginsDir string `json:"userPluginsDir"`
|
||||||
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
||||||
|
Sync SyncSettings `json:"sync,omitempty"`
|
||||||
WindowState *WindowState `json:"windowState,omitempty"`
|
WindowState *WindowState `json:"windowState,omitempty"`
|
||||||
LastOpenedAt string `json:"lastOpenedAt"`
|
LastOpenedAt string `json:"lastOpenedAt"`
|
||||||
}
|
}
|
||||||
|
|
@ -31,6 +32,18 @@ type WorkbenchPreferences struct {
|
||||||
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
|
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncSettings holds sync configuration for the current vault.
|
||||||
|
type SyncSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ServerURL string `json:"serverUrl"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
DeviceName string `json:"deviceName"`
|
||||||
|
SyncInterval int `json:"syncInterval"`
|
||||||
|
LastStatus string `json:"lastStatus"`
|
||||||
|
LastSyncAt string `json:"lastSyncAt"`
|
||||||
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// WindowState stores the last window position and size.
|
// WindowState stores the last window position and size.
|
||||||
type WindowState struct {
|
type WindowState struct {
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
|
|
@ -234,6 +247,7 @@ func copyConfig(c *Config) *Config {
|
||||||
DevMode: c.DevMode,
|
DevMode: c.DevMode,
|
||||||
UserPluginsDir: c.UserPluginsDir,
|
UserPluginsDir: c.UserPluginsDir,
|
||||||
Workbench: c.Workbench,
|
Workbench: c.Workbench,
|
||||||
|
Sync: c.Sync,
|
||||||
LastOpenedAt: c.LastOpenedAt,
|
LastOpenedAt: c.LastOpenedAt,
|
||||||
}
|
}
|
||||||
if c.WindowState != nil {
|
if c.WindowState != nil {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeviceTokenPath returns the path to the device_token file inside the vault.
|
||||||
|
func DeviceTokenPath(vaultRoot string) string {
|
||||||
|
return filepath.Join(vaultRoot, ".verstak", "device_token.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveDeviceToken writes the device token to a file with 0600 perms.
|
||||||
|
func SaveDeviceToken(vaultRoot, token string) error {
|
||||||
|
path := DeviceTokenPath(vaultRoot)
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data := fmt.Sprintf(`{"device_token":%q}`, token)
|
||||||
|
return os.WriteFile(path, []byte(data), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDeviceToken reads the device token from the vault.
|
||||||
|
func LoadDeviceToken(vaultRoot string) string {
|
||||||
|
path := DeviceTokenPath(vaultRoot)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
DeviceToken string `json:"device_token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v.DeviceToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDeviceToken deletes the device token file.
|
||||||
|
func RemoveDeviceToken(vaultRoot string) error {
|
||||||
|
path := DeviceTokenPath(vaultRoot)
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client communicates with the Verstak Sync Server.
|
||||||
|
type Client struct {
|
||||||
|
ServerURL string
|
||||||
|
APIKey string
|
||||||
|
DeviceToken string
|
||||||
|
DeviceID string
|
||||||
|
VaultRoot string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a sync client.
|
||||||
|
func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
|
||||||
|
return &Client{
|
||||||
|
ServerURL: serverURL,
|
||||||
|
APIKey: apiKey,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
VaultRoot: vaultRoot,
|
||||||
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PairDevice calls POST /api/client/pair and returns device_id and device_token.
|
||||||
|
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) {
|
||||||
|
body := map[string]string{
|
||||||
|
"login": username,
|
||||||
|
"password": password,
|
||||||
|
"device_name": deviceName,
|
||||||
|
"client_version": clientVersion,
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceToken string `json:"device_token"`
|
||||||
|
}
|
||||||
|
savedURL := c.ServerURL
|
||||||
|
c.ServerURL = serverURL
|
||||||
|
err = c.post("/api/client/pair", body, &resp)
|
||||||
|
c.ServerURL = savedURL
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return resp.DeviceID, resp.DeviceToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceInfo holds device information from the server.
|
||||||
|
type DeviceInfo struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
ClientVersion string `json:"client_version"`
|
||||||
|
LastSeen string `json:"last_seen"`
|
||||||
|
RevokedAt string `json:"revoked_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMe calls GET /api/client/me and returns device info.
|
||||||
|
func (c *Client) GetMe() (*DeviceInfo, error) {
|
||||||
|
var resp DeviceInfo
|
||||||
|
if err := c.get("/api/client/me", &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeCurrent calls POST /api/client/revoke-current.
|
||||||
|
func (c *Client) RevokeCurrent() error {
|
||||||
|
var resp struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
return c.post("/api/client/revoke-current", nil, &resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuth checks credentials without creating a device.
|
||||||
|
func (c *Client) TestAuth(serverURL, username, password string) error {
|
||||||
|
// First, check if this is a Verstak Sync server
|
||||||
|
healthURL := strings.TrimSuffix(serverURL, "/") + "/api/v1/health"
|
||||||
|
req, err := http.NewRequest("GET", healthURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connection failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("not a Verstak Sync server (HTTP %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
body := string(data)
|
||||||
|
if !strings.Contains(body, "status") && !strings.Contains(body, "ok") {
|
||||||
|
return fmt.Errorf("not a Verstak Sync server (unexpected response)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now test actual auth
|
||||||
|
authBody := map[string]string{"username": username, "password": password}
|
||||||
|
savedURL := c.ServerURL
|
||||||
|
savedKey := c.APIKey
|
||||||
|
c.ServerURL = serverURL
|
||||||
|
c.APIKey = ""
|
||||||
|
err = c.post("/api/auth/test", authBody, nil)
|
||||||
|
c.ServerURL = savedURL
|
||||||
|
c.APIKey = savedKey
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushRequest is the payload for POST /sync/push.
|
||||||
|
type PushRequest struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
|
Ops []PushOp `json:"ops"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushOp is a single operation in a push request.
|
||||||
|
type PushOp struct {
|
||||||
|
OpID string `json:"op_id"`
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
EntityID string `json:"entity_id"`
|
||||||
|
OpType string `json:"op_type"`
|
||||||
|
PayloadJSON string `json:"payload_json"`
|
||||||
|
ClientSequence int `json:"client_sequence"`
|
||||||
|
LastSeenServerSeq int `json:"last_seen_server_seq"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushResponse is the response from POST /sync/push.
|
||||||
|
type PushResponse struct {
|
||||||
|
Accepted []string `json:"accepted"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Conflicts []map[string]interface{} `json:"conflicts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push sends local operations to the server.
|
||||||
|
func (c *Client) Push(ops []Op) (*PushResponse, error) {
|
||||||
|
pushOps := make([]PushOp, len(ops))
|
||||||
|
for i, op := range ops {
|
||||||
|
pushOps[i] = PushOp{
|
||||||
|
OpID: op.OpID,
|
||||||
|
EntityType: op.EntityType,
|
||||||
|
EntityID: op.EntityID,
|
||||||
|
OpType: op.OpType,
|
||||||
|
PayloadJSON: op.PayloadJSON,
|
||||||
|
ClientSequence: op.ClientSequence,
|
||||||
|
LastSeenServerSeq: op.LastSeenServerSeq,
|
||||||
|
CreatedAt: op.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := PushRequest{DeviceID: c.DeviceID, Ops: pushOps}
|
||||||
|
var resp PushResponse
|
||||||
|
if err := c.post("/api/v1/sync/push", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullRequest is the payload for POST /sync/pull.
|
||||||
|
type PullRequest struct {
|
||||||
|
SinceSequence int `json:"since_sequence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullResponse is the response from POST /sync/pull.
|
||||||
|
type PullResponse struct {
|
||||||
|
ServerSequence int `json:"server_sequence"`
|
||||||
|
Ops []Op `json:"ops"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull fetches remote operations since a given sequence.
|
||||||
|
func (c *Client) Pull(sinceSequence int) (*PullResponse, error) {
|
||||||
|
req := PullRequest{SinceSequence: sinceSequence}
|
||||||
|
var resp PullResponse
|
||||||
|
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadBlob uploads a file to the server and returns its SHA-256.
|
||||||
|
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&b)
|
||||||
|
fw, err := w.CreateFormFile("file", filepath.Base(localPath))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
f, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := io.Copy(fw, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.SHA256, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadBlob downloads a blob by SHA-256 hash.
|
||||||
|
func (c *Client) DownloadBlob(sha256, destPath string) error {
|
||||||
|
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) bearerToken() string {
|
||||||
|
if c.DeviceToken != "" {
|
||||||
|
return c.DeviceToken
|
||||||
|
}
|
||||||
|
return c.APIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) post(path string, body, result interface{}) error {
|
||||||
|
var b bytes.Buffer
|
||||||
|
if body != nil {
|
||||||
|
if err := json.NewEncoder(&b).Encode(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return c.readErrorBody(resp, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
return json.NewDecoder(resp.Body).Decode(result)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(path string, result interface{}) error {
|
||||||
|
req, err := http.NewRequest("GET", c.ServerURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return c.readErrorBody(resp, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
return json.NewDecoder(resp.Body).Decode(result)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readErrorBody(resp *http.Response, statusCode int) error {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
n, _ := io.ReadFull(resp.Body, buf)
|
||||||
|
body := string(buf[:minInt(n, 500)])
|
||||||
|
|
||||||
|
lower := strings.ToLower(body)
|
||||||
|
if strings.Contains(lower, "<html") || strings.Contains(lower, "<!doctype") {
|
||||||
|
return fmt.Errorf("not a Verstak Sync server (HTTP %d)", statusCode)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("server error (HTTP %d)", statusCode)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,289 @@
|
||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
EntityNode = "node"
|
||||||
|
EntityNote = "note"
|
||||||
|
EntityFile = "file"
|
||||||
|
EntityFolder = "folder"
|
||||||
|
EntityAction = "action"
|
||||||
|
EntityWorklog = "worklog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpCreate = "create"
|
||||||
|
OpUpdate = "update"
|
||||||
|
OpDelete = "delete"
|
||||||
|
OpMove = "move"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Op represents a sync operation.
|
||||||
|
type Op struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OpID string `json:"op_id"`
|
||||||
|
ServerSequence int `json:"server_sequence,omitempty"`
|
||||||
|
DeviceID string `json:"device_id,omitempty"`
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
EntityID string `json:"entity_id"`
|
||||||
|
OpType string `json:"op_type"`
|
||||||
|
PayloadJSON string `json:"payload_json"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
PushedAt *string `json:"pushed_at,omitempty"`
|
||||||
|
AppliedAt *string `json:"applied_at,omitempty"`
|
||||||
|
ClientSequence int `json:"client_sequence,omitempty"`
|
||||||
|
LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncState persists connection state to JSON file.
|
||||||
|
type syncState struct {
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
LastPullSeq int `json:"last_pull_seq"`
|
||||||
|
LastSyncAt string `json:"last_sync_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service records and manages sync operations using JSON file storage.
|
||||||
|
type Service struct {
|
||||||
|
vaultRoot string
|
||||||
|
deviceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a sync service.
|
||||||
|
func NewService(vaultRoot, deviceID string) *Service {
|
||||||
|
return &Service{vaultRoot: vaultRoot, deviceID: deviceID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncDir() string {
|
||||||
|
return filepath.Join(s.vaultRoot, ".verstak", "sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) opsPath() string {
|
||||||
|
return filepath.Join(s.syncDir(), "ops.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) statePath() string {
|
||||||
|
return filepath.Join(s.syncDir(), "state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureDir() error {
|
||||||
|
return os.MkdirAll(s.syncDir(), 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordOp writes a sync operation to the local ops file.
|
||||||
|
func (s *Service) RecordOp(entityType, entityID, opType string, payload interface{}) error {
|
||||||
|
if err := s.ensureDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := uuid.New().String()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
var payloadStr string
|
||||||
|
if payload != nil {
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payloadStr = string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
op := Op{
|
||||||
|
ID: id,
|
||||||
|
OpID: id,
|
||||||
|
DeviceID: s.deviceID,
|
||||||
|
EntityType: entityType,
|
||||||
|
EntityID: entityID,
|
||||||
|
OpType: opType,
|
||||||
|
PayloadJSON: payloadStr,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
ops, err := s.loadOps()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ops = append(ops, op)
|
||||||
|
return s.saveOps(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRemoteOp writes a remote op to the local ops file.
|
||||||
|
func (s *Service) RecordRemoteOp(op Op) error {
|
||||||
|
if err := s.ensureDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
ops, err := s.loadOps()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remoteID := op.OpID + "-remote"
|
||||||
|
for _, existing := range ops {
|
||||||
|
if existing.ID == remoteID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
op.ID = remoteID
|
||||||
|
op.PushedAt = &now
|
||||||
|
op.AppliedAt = &now
|
||||||
|
ops = append(ops, op)
|
||||||
|
return s.saveOps(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnpushedOps returns ops that have not been pushed yet.
|
||||||
|
func (s *Service) GetUnpushedOps() ([]Op, error) {
|
||||||
|
ops, err := s.loadOps()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var unpushed []Op
|
||||||
|
for _, op := range ops {
|
||||||
|
if op.PushedAt == nil {
|
||||||
|
unpushed = append(unpushed, op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unpushed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPushed marks ops as pushed to server.
|
||||||
|
func (s *Service) MarkPushed(opIDs []string) error {
|
||||||
|
ops, err := s.loadOps()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
idSet := make(map[string]bool, len(opIDs))
|
||||||
|
for _, id := range opIDs {
|
||||||
|
idSet[id] = true
|
||||||
|
}
|
||||||
|
for i := range ops {
|
||||||
|
if idSet[ops[i].OpID] {
|
||||||
|
ops[i].PushedAt = &now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.saveOps(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkApplied marks remote ops as applied locally.
|
||||||
|
func (s *Service) MarkApplied(opIDs []string) error {
|
||||||
|
ops, err := s.loadOps()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
idSet := make(map[string]bool, len(opIDs))
|
||||||
|
for _, id := range opIDs {
|
||||||
|
idSet[id] = true
|
||||||
|
}
|
||||||
|
for i := range ops {
|
||||||
|
if idSet[ops[i].OpID] {
|
||||||
|
ops[i].AppliedAt = &now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.saveOps(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState returns the current sync state.
|
||||||
|
func (s *Service) GetState() (serverURL, apiKey string, lastPullSeq int, lastSyncAt string, err error) {
|
||||||
|
st, err := s.loadState()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, "", err
|
||||||
|
}
|
||||||
|
return st.ServerURL, st.APIKey, st.LastPullSeq, st.LastSyncAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetState saves sync connection state.
|
||||||
|
func (s *Service) SetState(serverURL, apiKey string) error {
|
||||||
|
if err := s.ensureDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.loadState()
|
||||||
|
if err != nil {
|
||||||
|
st = &syncState{}
|
||||||
|
}
|
||||||
|
st.ServerURL = serverURL
|
||||||
|
st.APIKey = apiKey
|
||||||
|
return s.saveState(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastPullSeq updates the last pulled server sequence.
|
||||||
|
func (s *Service) SetLastPullSeq(seq int) error {
|
||||||
|
st, err := s.loadState()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.LastPullSeq = seq
|
||||||
|
return s.saveState(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastSyncAt updates the last sync timestamp.
|
||||||
|
func (s *Service) SetLastSyncAt(t string) error {
|
||||||
|
st, err := s.loadState()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.LastSyncAt = t
|
||||||
|
return s.saveState(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceID returns the device ID used by this service.
|
||||||
|
func (s *Service) GetDeviceID() string {
|
||||||
|
return s.deviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- file helpers ---
|
||||||
|
|
||||||
|
func (s *Service) loadOps() ([]Op, error) {
|
||||||
|
data, err := os.ReadFile(s.opsPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read ops: %w", err)
|
||||||
|
}
|
||||||
|
var ops []Op
|
||||||
|
if err := json.Unmarshal(data, &ops); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ops: %w", err)
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) saveOps(ops []Op) error {
|
||||||
|
data, err := json.MarshalIndent(ops, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal ops: %w", err)
|
||||||
|
}
|
||||||
|
return os.WriteFile(s.opsPath(), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadState() (*syncState, error) {
|
||||||
|
data, err := os.ReadFile(s.statePath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &syncState{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read state: %w", err)
|
||||||
|
}
|
||||||
|
var st syncState
|
||||||
|
if err := json.Unmarshal(data, &st); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse state: %w", err)
|
||||||
|
}
|
||||||
|
return &st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) saveState(st *syncState) error {
|
||||||
|
data, err := json.MarshalIndent(st, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal state: %w", err)
|
||||||
|
}
|
||||||
|
return os.WriteFile(s.statePath(), data, 0o644)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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 {
|
if err != nil {
|
||||||
t.Fatalf("expected 1 root node, got %d", len(tree.Nodes))
|
t.Fatalf("ListWorkspaces: %v", err)
|
||||||
}
|
}
|
||||||
if tree.Nodes[0].Type != TypeSpace {
|
|
||||||
t.Errorf("root type: got %s, want %s", tree.Nodes[0].Type, TypeSpace)
|
got := workspaceNames(workspaces)
|
||||||
}
|
want := []string{"Project", "Test"}
|
||||||
if tree.Nodes[0].Title != "My Workspace" {
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
t.Errorf("root title: got %q, want %q", tree.Nodes[0].Title, "My Workspace")
|
t.Fatalf("workspaces = %v, want %v", got, want)
|
||||||
}
|
|
||||||
if tree.CurrentNodeID != tree.Nodes[0].ID {
|
|
||||||
t.Errorf("current node should be root")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateNode_Case(t *testing.T) {
|
func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) {
|
||||||
dir := t.TempDir()
|
if runtime.GOOS == "windows" {
|
||||||
vaultDir := filepath.Join(dir, "vault")
|
t.Skip("symlink creation needs extra privileges on Windows")
|
||||||
verstakDir := filepath.Join(vaultDir, ".verstak")
|
}
|
||||||
os.MkdirAll(verstakDir, 0o755)
|
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)
|
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)
|
||||||
}
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(vaultDir, "Old Tree Workspace")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("Load created folder from old workspace.json, stat err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
rootID := m.GetTree().Nodes[0].ID
|
workspaces, err := m.ListWorkspaces()
|
||||||
|
|
||||||
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 {
|
if len(workspaces) != 0 {
|
||||||
t.Errorf("type: got %s, want %s", node.Type, TypeCase)
|
t.Fatalf("workspace.json tree should not be source of truth, got %+v", workspaces)
|
||||||
}
|
}
|
||||||
if node.Title != "Test Case" {
|
}
|
||||||
t.Errorf("title: got %q, want %q", node.Title, "Test Case")
|
|
||||||
|
func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testing.T) {
|
||||||
|
vaultDir := newVaultDir(t)
|
||||||
|
m := NewManager(vaultDir)
|
||||||
|
|
||||||
|
ws, err := m.CreateWorkspace("Project", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWorkspace: %v", err)
|
||||||
}
|
}
|
||||||
if node.ParentID != rootID {
|
if ws.Name != "Project" || ws.RootPath != "Project" {
|
||||||
t.Errorf("parentID: got %q, want %q", node.ParentID, rootID)
|
t.Fatalf("workspace = %+v, want Project root", ws)
|
||||||
}
|
}
|
||||||
if node.Status != StatusActive {
|
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); err != nil {
|
||||||
t.Errorf("status: got %s, want %s", node.Status, StatusActive)
|
t.Fatalf("workspace folder missing: %v", err)
|
||||||
}
|
}
|
||||||
if node.Path != filepath.Join("My Workspace", "Test Case") {
|
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil {
|
||||||
t.Errorf("path: got %q, want %q", node.Path, filepath.Join("My Workspace", "Test Case"))
|
t.Fatalf("default template overview missing: %v", err)
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(vaultDir, node.Path)); err != nil {
|
|
||||||
t.Fatalf("expected workspace folder to exist: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify persisted
|
meta, err := m.GetWorkspaceMetadata("Project")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkspaceMetadata: %v", err)
|
||||||
|
}
|
||||||
|
if meta.WorkspaceName != "Project" {
|
||||||
|
t.Fatalf("metadata workspaceName = %q", meta.WorkspaceName)
|
||||||
|
}
|
||||||
|
if meta.CreatedFromTemplate == nil {
|
||||||
|
t.Fatal("metadata missing createdFromTemplate snapshot")
|
||||||
|
}
|
||||||
|
if meta.CreatedFromTemplate.TemplateID != "default" || meta.CreatedFromTemplate.TemplateName == "" || meta.CreatedFromTemplate.TemplateVersion == 0 || meta.CreatedFromTemplate.AppliedAt == "" {
|
||||||
|
t.Fatalf("bad template snapshot: %+v", meta.CreatedFromTemplate)
|
||||||
|
}
|
||||||
|
if !meta.Features["files"] || !meta.Features["notes"] {
|
||||||
|
t.Fatalf("features = %+v, want files and notes enabled", meta.Features)
|
||||||
|
}
|
||||||
|
if meta.Folders["notes"] != "Notes" {
|
||||||
|
t.Fatalf("folders = %+v, want notes folder", meta.Folders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) {
|
||||||
|
vaultDir := newVaultDir(t)
|
||||||
|
m := NewManager(vaultDir)
|
||||||
|
if _, err := m.CreateWorkspace("ClientA", "client-project"); err != nil {
|
||||||
|
t.Fatalf("CreateWorkspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearTemplateRegistryForTest(t)
|
||||||
|
|
||||||
|
meta, err := m.GetWorkspaceMetadata("ClientA")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkspaceMetadata after registry clear: %v", err)
|
||||||
|
}
|
||||||
|
if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" {
|
||||||
|
t.Fatalf("snapshot not preserved after registry clear: %+v", meta.CreatedFromTemplate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingMetadataReturnsGenericWorkspaceMetadata(t *testing.T) {
|
||||||
|
vaultDir := newVaultDir(t)
|
||||||
|
mustMkdir(t, filepath.Join(vaultDir, "Loose"))
|
||||||
|
|
||||||
|
m := NewManager(vaultDir)
|
||||||
|
meta, err := m.GetWorkspaceMetadata("Loose")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkspaceMetadata: %v", err)
|
||||||
|
}
|
||||||
|
if meta.WorkspaceName != "Loose" {
|
||||||
|
t.Fatalf("workspaceName = %q", meta.WorkspaceName)
|
||||||
|
}
|
||||||
|
if meta.CreatedFromTemplate != nil {
|
||||||
|
t.Fatalf("generic metadata should not invent a template snapshot: %+v", meta.CreatedFromTemplate)
|
||||||
|
}
|
||||||
|
if !meta.Features["files"] {
|
||||||
|
t.Fatalf("generic metadata should enable files at minimum: %+v", meta.Features)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWorkspaceMetadataReturnsCanonicalFolderNameWhenStoredNameIsStale(t *testing.T) {
|
||||||
|
vaultDir := newVaultDir(t)
|
||||||
|
m := NewManager(vaultDir)
|
||||||
|
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
|
||||||
|
t.Fatalf("CreateWorkspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(m.metadataPath("Project"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read metadata: %v", err)
|
||||||
|
}
|
||||||
|
var meta Metadata
|
||||||
|
if err := json.Unmarshal(data, &meta); err != nil {
|
||||||
|
t.Fatalf("unmarshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
meta.WorkspaceName = "OldName"
|
||||||
|
staleData, err := json.MarshalIndent(meta, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(m.metadataPath("Project"), staleData, 0o600); err != nil {
|
||||||
|
t.Fatalf("write stale metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := m.GetWorkspaceMetadata("Project")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkspaceMetadata: %v", err)
|
||||||
|
}
|
||||||
|
if got.WorkspaceName != "Project" {
|
||||||
|
t.Fatalf("workspaceName = %q, want canonical folder name Project", got.WorkspaceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenameWorkspacePhysicallyRenamesFolderAndMetadata(t *testing.T) {
|
||||||
|
vaultDir := newVaultDir(t)
|
||||||
|
m := NewManager(vaultDir)
|
||||||
|
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
|
||||||
|
t.Fatalf("CreateWorkspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.RenameWorkspace("Project", "Renamed"); err != nil {
|
||||||
|
t.Fatalf("RenameWorkspace: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("old folder still exists or stat failed unexpectedly: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); err != nil {
|
||||||
|
t.Fatalf("renamed folder missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := m.GetWorkspaceMetadata("Renamed")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("metadata after rename: %v", err)
|
||||||
|
}
|
||||||
|
if meta.WorkspaceName != "Renamed" {
|
||||||
|
t.Fatalf("metadata workspaceName = %q, want Renamed", meta.WorkspaceName)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(m.metadataPath("Project")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("old metadata key still exists or stat failed unexpectedly: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrashWorkspaceMovesFolderToTrashAndRemovesFromList(t *testing.T) {
|
||||||
|
vaultDir := newVaultDir(t)
|
||||||
|
m := NewManager(vaultDir)
|
||||||
|
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
|
||||||
|
t.Fatalf("CreateWorkspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := m.TrashWorkspace("Project")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TrashWorkspace: %v", err)
|
||||||
|
}
|
||||||
|
if result.OriginalPath != "Project" || result.TrashID == "" || result.TrashPath == "" {
|
||||||
|
t.Fatalf("bad trash result: %+v", result)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("workspace still exists after trash, stat err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(vaultDir, filepath.FromSlash(result.TrashPath))); err != nil {
|
||||||
|
t.Fatalf("trashed workspace missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaces, err := m.ListWorkspaces()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWorkspaces: %v", err)
|
||||||
|
}
|
||||||
|
if len(workspaces) != 0 {
|
||||||
|
t.Fatalf("workspace should be removed from list after trash, got %+v", workspaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
|
||||||
|
vaultDir := newVaultDir(t)
|
||||||
|
mustMkdir(t, filepath.Join(vaultDir, "Existing"))
|
||||||
|
mustMkdir(t, filepath.Join(vaultDir, "Other"))
|
||||||
|
m := NewManager(vaultDir)
|
||||||
|
|
||||||
|
if _, err := m.CreateWorkspace("Existing", ""); err == nil || !strings.Contains(err.Error(), "conflict") {
|
||||||
|
t.Fatalf("create conflict error = %v, want conflict", err)
|
||||||
|
}
|
||||||
|
if err := m.RenameWorkspace("Existing", "Other"); err == nil || !strings.Contains(err.Error(), "conflict") {
|
||||||
|
t.Fatalf("rename conflict error = %v, want conflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidWorkspaceNamesRejected(t *testing.T) {
|
||||||
|
vaultDir := newVaultDir(t)
|
||||||
|
m := NewManager(vaultDir)
|
||||||
|
|
||||||
|
names := []string{"", " ", "A/B", `A\B`, "/abs", `C:\abs`, "..", "a..b", "bad\x00name", ".verstak", ".Verstak", ".git"}
|
||||||
|
for _, name := range names {
|
||||||
|
if _, err := m.CreateWorkspace(name, ""); err == nil {
|
||||||
|
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid name error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
|
||||||
|
vaultDir := newVaultDir(t)
|
||||||
|
mustMkdir(t, filepath.Join(vaultDir, "Project"))
|
||||||
|
mustMkdir(t, filepath.Join(vaultDir, "Project", "Nested"))
|
||||||
|
mustMkdir(t, filepath.Join(vaultDir, "Test"))
|
||||||
|
|
||||||
|
m := NewManager(vaultDir)
|
||||||
|
if err := m.Load(); err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
tree := m.GetTree()
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
main.go
7
main.go
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||||
|
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||||
"github.com/verstak/verstak-desktop/internal/shell/debug"
|
"github.com/verstak/verstak-desktop/internal/shell/debug"
|
||||||
|
|
@ -245,7 +246,11 @@ func main() {
|
||||||
// Create the App struct
|
// Create the App struct
|
||||||
storageService := storage.New(vaultService)
|
storageService := storage.New(vaultService)
|
||||||
filesService := corefiles.NewService(vaultService)
|
filesService := corefiles.NewService(vaultService)
|
||||||
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, debugEnabled)
|
var syncService *syncsvc.Service
|
||||||
|
if vaultService.GetVaultStatus() == vault.StatusOpen {
|
||||||
|
syncService = syncsvc.NewService(vaultService.GetVaultPath(), "")
|
||||||
|
}
|
||||||
|
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled)
|
||||||
|
|
||||||
// ─── Wails App ───────────────────────────────────────────
|
// ─── Wails App ───────────────────────────────────────────
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue