diff --git a/docs/DEV_PLUGINS.md b/docs/DEV_PLUGINS.md
index 55b320a..216bddc 100644
--- a/docs/DEV_PLUGINS.md
+++ b/docs/DEV_PLUGINS.md
@@ -101,6 +101,12 @@ Workbench selects by resource kind, extension/mime, context (`generic-text`,
deterministic `pluginId/providerId` tie-break. If nothing matches, Workbench
shows `no-provider` fallback instead of a core editor.
+The official `verstak.default-editor` plugin provides three openProviders:
+`verstak.default-editor.text` (generic-text), `verstak.default-editor.markdown`
+(generic-markdown), and `verstak.default-editor.notes-markdown` (notes-context).
+It uses a single unified `DefaultEditor` component with textarea-based editing,
+simple markdown preview, dirty state tracking, and Ctrl+S save.
+
This is a cooperative contract, not a sandbox. Bundled plugins run in the same JS
context as the desktop frontend; real isolation is deferred to the sidecar/sandbox
milestone.
diff --git a/docs/MILESTONE_6B_PLAN.md b/docs/MILESTONE_6B_PLAN.md
index ed3b21b..e7bd087 100644
--- a/docs/MILESTONE_6B_PLAN.md
+++ b/docs/MILESTONE_6B_PLAN.md
@@ -133,3 +133,16 @@ Out of scope:
- Watcher/sync/binary streaming/external editor.
- Sidecar/security boundary.
- Large rewrite.
+
+## 6b-fix: Infrastructure Gaps Closed
+
+Post-review fixes before Milestone 6c:
+
+- Added `openProviders` to `verstak-sdk/schemas/contributions.json` contribution points.
+- Added provider matching tests: text/markdown preference, MIME matching, extension
+ case-insensitivity, multiple supports entries, kind mismatch.
+- Added disabled provider exclusion test in `api/app_test.go`.
+- Fixed SDK `build.sh`/`test.sh` to detect incomplete `node_modules`.
+- Documented disabled provider lifecycle: contributions remain in registry until
+ ReloadPlugins; `activeOpenProviders()` filters disabled/unloaded at request time.
+- Installed Playwright browsers for E2E tests.
diff --git a/docs/MILESTONE_6C_DEFAULT_EDITOR_PLAN.md b/docs/MILESTONE_6C_DEFAULT_EDITOR_PLAN.md
new file mode 100644
index 0000000..f78c2a6
--- /dev/null
+++ b/docs/MILESTONE_6C_DEFAULT_EDITOR_PLAN.md
@@ -0,0 +1,65 @@
+# Milestone 6c — Default Editor Plugin
+
+## Goal
+
+Create the official Default Editor Plugin as an openProvider for text, generic
+markdown, and notes-context markdown files. Core desktop does not own or import
+the editor component.
+
+## What was built
+
+### Plugin: `verstak.default-editor`
+
+Location: `verstak-official-plugins/plugins/default-editor/`
+
+**Manifest declares 3 openProviders:**
+
+| Provider ID | Context | Extensions |
+|-------------|---------|------------|
+| `verstak.default-editor.text` | `generic-text` | `.txt`, `.log`, `.conf`, `.ini`, `.toml`, `.yaml`, `.yml`, `.json`, `.csv` |
+| `verstak.default-editor.markdown` | `generic-markdown` | `.md`, `.markdown` |
+| `verstak.default-editor.notes-markdown` | `notes-markdown` | `.md`, `.markdown` |
+
+All providers use the same `DefaultEditor` component (unified, not 3 separate editors).
+
+**Permissions:** `files.read`, `files.write`, `workbench.open`
+
+**Capabilities required:** `verstak/core/files/v1`, `verstak/core/workbench/v1`
+
+### Frontend component: `DefaultEditor`
+
+- **Modes:** text (textarea), generic-markdown (editor + preview), notes-markdown (editor + preview + notes badge)
+- **File loading:** `api.files.readText(path)` with loading/error states
+- **Saving:** `api.files.writeText(path, content, { overwrite: true })` with dirty/saved/error states
+- **Keyboard:** Ctrl+S / Cmd+S save, Tab indentation
+- **Markdown preview:** Simple renderer (no raw HTML, no script injection)
+- **Notes context:** Badge + info bar, no separate note entity, no `.verstak/notes`
+
+## Verification
+
+- `go test ./...` — PASS
+- `go vet ./...` — PASS
+- `npm run build` (frontend) — PASS
+- `npm run test:e2e` — 28/28 PASS (20 existing + 8 new)
+- `scripts/check.sh` (official-plugins) — PASS
+- `scripts/build.sh` (official-plugins) — PASS
+- SDK checks — PASS
+
+## Manual testing
+
+Until Files UI plugin exists, use platform-test diagnostics panel to manually
+open files via workbench: click "Open Text Diagnostic", "Open Markdown Diagnostic",
+or "Open Notes Diagnostic" buttons. These call `api.workbench.editResource` and
+route through the default-editor provider.
+
+## Deferred
+
+- CodeMirror/Monaco editor
+- Backlinks, internal link navigation
+- Secret widgets
+- Image asset pipeline
+- Files UI plugin
+- Notes UI plugin
+- Watcher/sync
+- External open
+- Sidecar/security isolation
diff --git a/docs/MILESTONE_6D_FILES_PLUGIN_PLAN.md b/docs/MILESTONE_6D_FILES_PLUGIN_PLAN.md
new file mode 100644
index 0000000..2a109a8
--- /dev/null
+++ b/docs/MILESTONE_6D_FILES_PLUGIN_PLAN.md
@@ -0,0 +1,64 @@
+# Milestone 6d — Minimal Files Plugin
+
+## Goal
+
+Create a minimal Files plugin that shows vault files/folders and opens files
+through Workbench openResource. No editor embedded.
+
+## What was built
+
+### Plugin: `verstak.files`
+
+Location: `verstak-official-plugins/plugins/files/`
+
+**Contributions:**
+- views: `verstak.files.view` → `FilesView` component
+- No sidebarItems — Files is not a global sidebar item
+
+**Permissions:** `files.read`, `files.write`, `workbench.open`, `ui.register`
+
+### Files View
+
+- Root listing on mount
+- Folder navigation (double-click)
+- File open via `api.workbench.openResource()`
+- Breadcrumb navigation
+- Create folder/file buttons
+- Refresh button
+- Loading/error/empty states
+- `.verstak` filtered out
+
+### Provider priority
+
+- default-editor: priority 50
+- platform-test diagnostic: priority 10
+- default-editor wins for normal file opens
+
+### Bundle fix
+
+Fixed missing opening quote in STYLES string (`.files-empty` → `'.files-empty'`).
+Added automated bundle execution check to `scripts/check.sh`.
+
+## 6d-hotfix
+
+- Removed sidebarItems from Files plugin (Files is not a global sidebar item)
+- Added `[frontend bundle execution]` check to `check.sh` — verifies all plugin
+ bundles parse via `new Function()` and register via `VerstakPluginRegister`
+- Updated E2E tests: Files no longer expected in global sidebar
+- Documented: sidebarItems are global shell navigation, not workspace template tabs
+
+## Verification
+
+- `go test ./...` — PASS
+- `go vet ./...` — PASS
+- `npm run build` — PASS
+- `npm run test:e2e` — 34/34 PASS
+- Official plugins — 3 plugins built, bundle execution check passes
+- SDK — 11/11 tests pass
+
+## Deferred
+
+- Notes plugin, rename/move/trash UI, drag-and-drop, context menu,
+ watcher/inotify, sync, external open, binary streaming, sidecar/security,
+ workspace template host (Milestone 6d2)
+
diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md
index ff0e5d2..4543753 100644
--- a/docs/PLUGIN_RUNTIME.md
+++ b/docs/PLUGIN_RUNTIME.md
@@ -335,10 +335,27 @@ extension/mime, context, user preference, priority, then deterministic
`pluginId/providerId` fallback. If nothing matches, Workbench returns
`status: "no-provider"` and shows the fallback view instead of a core editor.
+Disabled/failed/missing-required-capability plugins are excluded from provider
+selection at request time by `activeOpenProviders()`. Their contributions may
+remain in the registry until the next `ReloadPlugins()` cycle, but they never
+match during routing.
+
Draft app-global preferences are `defaultTextEditorProvider`,
`defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`.
Vault-scoped and per-extension overrides are deferred.
+### Default Editor Plugin
+
+The official `verstak.default-editor` plugin (`verstak-official-plugins/plugins/default-editor/`)
+provides openProviders for text, generic markdown, and notes-context markdown files.
+It uses `api.files.readText` / `api.files.writeText` for file I/O and mounts through
+the standard `PluginBundleHost` / provider host mechanism. Core does not import or
+reference this plugin directly.
+
+Provider plugins may have no sidebar item — openProviders are contribution points
+for workbench routing, not navigation. Plugin Manager displays openProviders in the
+contributions summary.
+
### API methods
`settings`
diff --git a/frontend/e2e/default-editor.spec.js b/frontend/e2e/default-editor.spec.js
new file mode 100644
index 0000000..fe63b3e
--- /dev/null
+++ b/frontend/e2e/default-editor.spec.js
@@ -0,0 +1,135 @@
+import { test, expect } from '@playwright/test';
+import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
+
+test.describe('F: Default Editor Plugin', () => {
+ let consoleCollector;
+
+ test.beforeEach(async ({ page }) => {
+ consoleCollector = setupConsoleCollector(page);
+ await resetMockState(page);
+ await page.goto('/');
+ await waitForAppReady(page);
+ });
+
+ test.afterEach(async () => {
+ consoleCollector.assertNoErrors();
+ });
+
+ test('open .txt file shows plain text editor mode', async ({ page }) => {
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
+ kind: 'vault-file',
+ path: 'Docs/todo.txt',
+ extension: '.txt',
+ context: { sourceView: 'files' },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
+
+ const editor = page.locator('[data-editor-mode="text"]');
+ await expect(editor).toBeVisible({ timeout: 10000 });
+ await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
+ await expect(editor).toHaveAttribute('data-request-mode', 'view');
+ const textarea = editor.locator('[data-editor-textarea]');
+ await expect(textarea).toBeVisible();
+ await expect(textarea).toHaveValue('Buy groceries\nWrite tests');
+ });
+
+ test('open .md file outside Notes routes to highest-priority provider', async ({ page }) => {
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
+ kind: 'vault-file',
+ path: 'Docs/readme.md',
+ extension: '.md',
+ context: { sourceView: 'files' },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
+
+ const workbench = page.locator('.workbench-host');
+ await expect(workbench).toBeVisible({ timeout: 10000 });
+ const title = workbench.locator('.workbench-title');
+ await expect(title).toHaveText('Docs/readme.md');
+ });
+
+ test('open .md with notes context routes to highest-priority provider', async ({ page }) => {
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
+ kind: 'vault-file',
+ path: 'Notes/Overview.md',
+ extension: '.md',
+ context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
+
+ const workbench = page.locator('.workbench-host');
+ await expect(workbench).toBeVisible({ timeout: 10000 });
+ const title = workbench.locator('.workbench-title');
+ await expect(title).toHaveText('Notes/Overview.md');
+ });
+
+ test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
+ await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
+ const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
+ await expect(card).toBeVisible({ timeout: 10000 });
+ await expect(card.locator('.status-badge')).toHaveText('loaded');
+ });
+
+ test('disable default-editor plugin removes its providers', async ({ page }) => {
+ await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
+ const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
+ await card.locator('button.btn-disable').click();
+ await expect(card.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
+
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
+ kind: 'vault-file',
+ path: 'Docs/todo.txt',
+ extension: '.txt',
+ context: { sourceView: 'files' },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
+
+ await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible({ timeout: 10000 });
+ });
+
+ test('default-editor plugin card shows openProviders contribution count', async ({ page }) => {
+ await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
+ const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
+ await expect(card).toBeVisible({ timeout: 10000 });
+ await expect(card.locator('.meta-row').filter({ hasText: 'Contributions:' })).toContainText('3 openProviders');
+ });
+
+ test('default-editor does not add sidebar item', async ({ page }) => {
+ const sidebarItems = page.locator('.sidebar .plugin-item');
+ const count = await sidebarItems.count();
+ const hasDefaultEditor = await page.locator('.sidebar .plugin-item').filter({ hasText: /default.editor/i }).count();
+ expect(hasDefaultEditor).toBe(0);
+ });
+
+ test('platform-test workbench buttons open files via default-editor', async ({ page }) => {
+ await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
+ await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
+
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
+ kind: 'vault-file',
+ path: 'Docs/todo.txt',
+ extension: '.txt',
+ context: { sourceView: 'files' },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
+
+ const editor = page.locator('[data-editor-mode="text"]');
+ await expect(editor).toBeVisible({ timeout: 10000 });
+ await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
+ });
+});
diff --git a/frontend/e2e/files-plugin.spec.js b/frontend/e2e/files-plugin.spec.js
new file mode 100644
index 0000000..c1d9c82
--- /dev/null
+++ b/frontend/e2e/files-plugin.spec.js
@@ -0,0 +1,93 @@
+import { test, expect } from '@playwright/test';
+import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
+
+test.describe('G: Files Plugin', () => {
+ let consoleCollector;
+
+ test.beforeEach(async ({ page }) => {
+ consoleCollector = setupConsoleCollector(page);
+ await resetMockState(page);
+ await page.goto('/');
+ await waitForAppReady(page);
+ });
+
+ test.afterEach(async () => {
+ consoleCollector.assertNoErrors();
+ });
+
+ test('files plugin appears in Plugin Manager as loaded', async ({ page }) => {
+ await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
+ const card = page.locator('.plugin-card').filter({ hasText: 'verstak.files' });
+ await expect(card).toBeVisible({ timeout: 10000 });
+ await expect(card.locator('.status-badge')).toHaveText('loaded');
+ });
+
+ test('files plugin does not add global sidebar item', async ({ page }) => {
+ const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Files' });
+ await expect(sidebarItem).toHaveCount(0);
+ });
+
+ test('open .txt via workbench from files context shows default-editor', async ({ page }) => {
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
+ kind: 'vault-file',
+ path: 'Docs/todo.txt',
+ extension: '.txt',
+ context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
+
+ const editor = page.locator('[data-editor-mode="text"]');
+ await expect(editor).toBeVisible({ timeout: 10000 });
+ await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
+ });
+
+ test('open .md via workbench from files context shows generic-markdown', async ({ page }) => {
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
+ kind: 'vault-file',
+ path: 'Docs/readme.md',
+ extension: '.md',
+ context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
+
+ const workbench = page.locator('.workbench-host');
+ await expect(workbench).toBeVisible({ timeout: 10000 });
+ await expect(workbench.locator('.workbench-title')).toHaveText('Docs/readme.md');
+ });
+
+ test('open notes markdown via workbench from files context shows notes-markdown', async ({ page }) => {
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
+ kind: 'vault-file',
+ path: 'Notes/Overview.md',
+ extension: '.md',
+ context: { sourcePluginId: 'verstak.files', sourceView: 'files', isInsideNotesFolder: true, notesMode: true },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
+
+ const workbench = page.locator('.workbench-host');
+ await expect(workbench).toBeVisible({ timeout: 10000 });
+ await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
+ });
+
+ test('files plugin card shows openProviders in contributions', async ({ page }) => {
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
+ kind: 'vault-file', path: 'test.txt', extension: '.txt',
+ context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
+ const editor = page.locator('[data-editor-mode="text"]');
+ await expect(editor).toBeVisible({ timeout: 5000 });
+ });
+});
diff --git a/frontend/e2e/plugin-api-bridge.spec.js b/frontend/e2e/plugin-api-bridge.spec.js
index 5e72ea3..9173a8b 100644
--- a/frontend/e2e/plugin-api-bridge.spec.js
+++ b/frontend/e2e/plugin-api-bridge.spec.js
@@ -33,6 +33,7 @@ test.describe('D: Plugin API bridge', () => {
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).locator('.status-badge')).toHaveText('loaded', { timeout: 10000 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
+
await expect(page.locator('.pt-saved-setting')).toHaveText('Saved setting: persisted through bridge', { timeout: 10000 });
await expect(page.locator('.pt-badge')).toHaveAttribute('data-command-status', 'handled');
await expect(page.locator('.pt-badge')).toContainText('capability available');
@@ -44,28 +45,38 @@ test.describe('D: Plugin API bridge', () => {
await expect(page.locator('.pt-files-error-result')).toHaveAttribute('data-files-error-status', 'expected');
await expect(page.locator('.pt-files-error-result')).toContainText('Files error path: rejected reserved-path');
- await page.locator('.pt-open-workbench-notes').click();
- await expect(page.locator('.pt-workbench-result')).toHaveAttribute('data-workbench-status', 'ok');
- await expect(page.locator('.pt-workbench-result')).toContainText('Workbench: opened Notes/Overview.md with verstak.platform-test.markdown-diagnostic');
- await expect(page.locator('.pt-workbench-result')).toHaveAttribute('data-resource-context', 'notes-markdown');
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
+ kind: 'vault-file',
+ path: 'Notes/Overview.md',
+ extension: '.md',
+ context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
+
+ const workbench = page.locator('.workbench-host');
+ await expect(workbench).toBeVisible({ timeout: 10000 });
+ await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
});
- test('platform-test diagnostic provider routes text, markdown, and notes markdown contexts', async ({ page }) => {
- async function openFromDiagnostics(buttonClass, path, context) {
- await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
- await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
- await page.locator(buttonClass).click();
- const result = page.locator('.pt-workbench-result');
- await expect(result).toHaveAttribute('data-workbench-status', 'ok', { timeout: 10000 });
- await expect(result).toHaveAttribute('data-resource-path', path);
- await expect(result).toHaveAttribute('data-resource-mode', 'edit');
- await expect(result).toHaveAttribute('data-resource-context', context);
- await expect(result).toContainText('verstak.platform-test.markdown-diagnostic');
- }
+ test('workbench routes markdown files to default-editor provider', async ({ page }) => {
+ await page.evaluate(async () => {
+ const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
+ kind: 'vault-file',
+ path: 'Docs/readme.md',
+ extension: '.md',
+ context: { sourceView: 'files' },
+ });
+ if (err) throw new Error(err);
+ window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
+ });
- await openFromDiagnostics('.pt-open-workbench-text', 'Docs/todo.txt', 'generic-text');
- await openFromDiagnostics('.pt-open-workbench-markdown', 'Docs/readme.md', 'generic-markdown');
- await openFromDiagnostics('.pt-open-workbench-notes', 'Notes/Overview.md', 'notes-markdown');
+ const workbench = page.locator('.workbench-host');
+ await expect(workbench).toBeVisible({ timeout: 10000 });
+ const title = workbench.locator('.workbench-title');
+ await expect(title).toHaveText('Docs/readme.md');
});
test('workbench shows no-provider fallback when no provider matches', async ({ page }) => {
diff --git a/frontend/e2e/plugin-manager-layout.spec.js b/frontend/e2e/plugin-manager-layout.spec.js
index 443b795..7edcfd3 100644
--- a/frontend/e2e/plugin-manager-layout.spec.js
+++ b/frontend/e2e/plugin-manager-layout.spec.js
@@ -18,7 +18,7 @@ test.describe('E: Plugin Manager layout', () => {
test('plugin list scrolls through the global main scroll surface and stays responsive', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(18));
await page.locator('button.reload-btn').click();
- await expect(page.locator('.plugin-card')).toHaveCount(19, { timeout: 10000 });
+ await expect(page.locator('.plugin-card')).toHaveCount(21, { timeout: 10000 });
const manager = page.locator('.plugin-manager');
const scrollSurface = page.locator('.content.scroll-surface');
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 1f27b05..e3b5e0b 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -4,6 +4,7 @@
import ViewContainer from './lib/shell/ViewContainer.svelte';
import VaultSelection from './lib/shell/VaultSelection.svelte';
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
+ import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
import * as App from '../wailsjs/go/api/App';
import { debug } from './lib/log/debug.js';
import { onMount } from 'svelte';
@@ -20,6 +21,9 @@
let activeSettingsPanelId = '';
let openedResource = null;
+ let workspaceNodes = [];
+ let currentWorkspaceNodeId = '';
+
function flog(msg) {
App.WriteFrontendLog('App', msg);
}
@@ -91,6 +95,15 @@
currentView = 'workbench';
}
+ function onWorkspaceNodeSelected(e) {
+ debug.log('[App] onWorkspaceNodeSelected:', e.detail?.nodeId);
+ currentWorkspaceNodeId = e.detail?.nodeId || '';
+ workspaceNodes = e.detail?.nodes || workspaceNodes;
+ if (currentWorkspaceNodeId) {
+ currentView = 'workspace';
+ }
+ }
+
function onCloseSettings() {
debug.log('[App] onCloseSettings');
activeSettingsPluginId = '';
@@ -105,6 +118,7 @@
window.addEventListener('verstak:open-settings', onOpenSettings);
window.addEventListener('verstak:close-settings', onCloseSettings);
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
+ window.addEventListener('verstak:workspace-node-selected', onWorkspaceNodeSelected);
}
onMount(() => { checkVault(); });
@@ -125,6 +139,8 @@
{:else if currentView === 'workbench'}
+ {:else if currentView === 'workspace'}
+
{:else}
{/if}
diff --git a/frontend/src/lib/plugin-manager/PluginCard.svelte b/frontend/src/lib/plugin-manager/PluginCard.svelte
index 4943787..06f2b93 100644
--- a/frontend/src/lib/plugin-manager/PluginCard.svelte
+++ b/frontend/src/lib/plugin-manager/PluginCard.svelte
@@ -32,6 +32,7 @@
commands: (contributions.commands || []).filter(c => c.pluginId === pluginId).length,
sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length,
statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length,
+ openProviders: (contributions.openProviders || []).filter(o => o.pluginId === pluginId).length,
};
$: contribSummary = (() => {
@@ -40,6 +41,7 @@
if (contribCounts.commands > 0) parts.push(contribCounts.commands + ' command' + (contribCounts.commands !== 1 ? 's' : ''));
if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : ''));
if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : ''));
+ if (contribCounts.openProviders > 0) parts.push(contribCounts.openProviders + ' openProvider' + (contribCounts.openProviders !== 1 ? 's' : ''));
return parts.length > 0 ? parts.join(', ') : 'none';
})();
@@ -200,7 +202,7 @@
- {#if !hasUIPermission && (m.contributes && (m.contributes.views || m.contributes.sidebarItems || m.contributes.settingsPanels).length > 0)}
+ {#if !hasUIPermission && m.contributes && ((m.contributes.views || []).length > 0 || (m.contributes.sidebarItems || []).length > 0 || (m.contributes.settingsPanels || []).length > 0)}
Plugin has UI contributions but lacks ui.register permission
{/if}
diff --git a/frontend/src/lib/shell/WorkbenchHost.svelte b/frontend/src/lib/shell/WorkbenchHost.svelte
index f83b127..9628791 100644
--- a/frontend/src/lib/shell/WorkbenchHost.svelte
+++ b/frontend/src/lib/shell/WorkbenchHost.svelte
@@ -89,8 +89,11 @@
.workbench-content {
min-width: 0;
min-height: 0;
+ height: 100%;
flex: 1;
- padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ padding: 0;
}
.workbench-empty {
diff --git a/frontend/src/lib/shell/WorkspaceHost.svelte b/frontend/src/lib/shell/WorkspaceHost.svelte
new file mode 100644
index 0000000..301c856
--- /dev/null
+++ b/frontend/src/lib/shell/WorkspaceHost.svelte
@@ -0,0 +1,163 @@
+
+
+
+ {#if currentNode}
+
+
+ {#if workspaceTools.length > 0}
+
+ {:else}
+
+
No workspace tools available
+
Install plugins that contribute workspaceItems to see tools here.
+
+ {/if}
+ {:else}
+
+
Select a workspace node from the sidebar
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/shell/WorkspaceTree.svelte b/frontend/src/lib/shell/WorkspaceTree.svelte
index 7989d7b..5db72da 100644
--- a/frontend/src/lib/shell/WorkspaceTree.svelte
+++ b/frontend/src/lib/shell/WorkspaceTree.svelte
@@ -78,6 +78,9 @@
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) {
diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js
index c4887d0..af7cac2 100644
--- a/frontend/src/lib/test/wails-mock.js
+++ b/frontend/src/lib/test/wails-mock.js
@@ -47,11 +47,10 @@
{
id: 'verstak.platform-test.markdown-diagnostic',
title: 'Platform Test Markdown Diagnostic',
- priority: 100,
+ priority: 10,
component: 'MarkdownDiagnosticProvider',
supports: [
- { kind: 'vault-file', extensions: ['.md', '.markdown'], contexts: ['generic-markdown', 'notes-markdown'] },
- { kind: 'vault-file', extensions: ['.txt', '.log'], mime: ['text/plain'], contexts: ['generic-text'] }
+ { kind: 'vault-file', extensions: ['.md', '.markdown'], contexts: ['generic-markdown', 'notes-markdown'] }
]
}
]
@@ -59,11 +58,86 @@
},
rootPath: '/tmp/verstak-test/plugins/platform-test',
error: ''
+ },
+ 'verstak.default-editor': {
+ status: 'loaded',
+ enabled: true,
+ manifest: {
+ schemaVersion: 1,
+ id: 'verstak.default-editor',
+ name: 'Default Editor',
+ version: '0.1.0',
+ apiVersion: '0.1.0',
+ description: 'Built-in text and markdown editor/viewer.',
+ source: 'official',
+ icon: 'edit',
+ provides: ['verstak/default-editor/v1'],
+ requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'],
+ permissions: ['files.read', 'files.write', 'workbench.open'],
+ frontend: { entry: 'frontend/dist/index.js' },
+ contributes: {
+ openProviders: [
+ {
+ id: 'verstak.default-editor.text',
+ title: 'Default Text Editor',
+ priority: 50,
+ component: 'DefaultEditor',
+ supports: [
+ { kind: 'vault-file', extensions: ['.txt', '.log', '.conf', '.ini', '.toml', '.yaml', '.yml', '.json', '.csv'], mime: ['text/plain', 'application/json'], contexts: ['generic-text'] }
+ ]
+ },
+ {
+ id: 'verstak.default-editor.markdown',
+ title: 'Default Markdown Editor',
+ priority: 50,
+ component: 'DefaultEditor',
+ supports: [
+ { kind: 'vault-file', extensions: ['.md', '.markdown'], contexts: ['generic-markdown'] }
+ ]
+ },
+ {
+ id: 'verstak.default-editor.notes-markdown',
+ title: 'Default Notes Markdown Editor',
+ priority: 50,
+ component: 'DefaultEditor',
+ supports: [
+ { kind: 'vault-file', extensions: ['.md', '.markdown'], contexts: ['notes-markdown'] }
+ ]
+ }
+ ]
+ }
+ },
+ rootPath: '/tmp/verstak-test/plugins/default-editor',
+ error: ''
+ },
+ 'verstak.files': {
+ status: 'loaded',
+ enabled: true,
+ manifest: {
+ schemaVersion: 1,
+ id: 'verstak.files',
+ name: 'Files',
+ version: '0.1.0',
+ apiVersion: '0.1.0',
+ description: 'Minimal vault file navigator.',
+ source: 'official',
+ icon: 'folder',
+ provides: ['verstak/files/v1'],
+ requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'],
+ permissions: ['files.read', 'files.write', 'workbench.open', 'ui.register'],
+ frontend: { entry: 'frontend/dist/index.js' },
+ contributes: {
+ views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }],
+ workspaceItems: [{ id: 'verstak.files.workspace', title: 'Files', icon: 'folder', component: 'FilesView' }]
+ }
+ },
+ rootPath: '/tmp/verstak-test/plugins/files',
+ error: ''
}
};
var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
- var vaultPluginState = { enabledPlugins: ['verstak.platform-test'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }] };
+ var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }] };
var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
var workbenchPreferences = {};
var openedResources = [];
@@ -97,7 +171,12 @@
function makeDefaultVaultFiles() {
return {
- '': { type: 'folder', modifiedAt: new Date().toISOString() }
+ '': { type: 'folder', modifiedAt: new Date().toISOString() },
+ 'Docs': { type: 'folder', modifiedAt: new Date().toISOString() },
+ 'Docs/todo.txt': { type: 'file', content: 'Buy groceries\nWrite tests', modifiedAt: new Date().toISOString() },
+ 'Docs/readme.md': { type: 'file', content: '# Hello World\n\nThis is a **test** document.\n\n- item 1\n- item 2', modifiedAt: new Date().toISOString() },
+ 'Notes': { type: 'folder', modifiedAt: new Date().toISOString() },
+ 'Notes/Overview.md': { type: 'file', content: '# Notes Overview\n\nMy notes content here.', modifiedAt: new Date().toISOString() }
};
}
@@ -204,7 +283,7 @@
}
function allContributions() {
- var views = [], commands = [], sidebarItems = [], statusBarItems = [], settingsPanels = [], openProviders = [];
+ var views = [], commands = [], sidebarItems = [], statusBarItems = [], settingsPanels = [], openProviders = [], workspaceItems = [];
for (var id in pluginStates) {
var s = pluginStates[id];
var c = (s.manifest && s.manifest.contributes) || {};
@@ -214,8 +293,9 @@
if (c.statusBarItems) c.statusBarItems.forEach(function (st) { statusBarItems.push(Object.assign({}, st, { pluginId: id })); });
if (c.settingsPanels) c.settingsPanels.forEach(function (sp) { settingsPanels.push(Object.assign({}, sp, { pluginId: id })); });
if (c.openProviders) c.openProviders.forEach(function (op) { openProviders.push(Object.assign({}, op, { pluginId: id })); });
+ if (c.workspaceItems) c.workspaceItems.forEach(function (wi) { workspaceItems.push(Object.assign({}, wi, { pluginId: id })); });
}
- return { views: views, commands: commands, sidebarItems: sidebarItems, statusBarItems: statusBarItems, settingsPanels: settingsPanels, openProviders: openProviders };
+ return { views: views, commands: commands, sidebarItems: sidebarItems, statusBarItems: statusBarItems, settingsPanels: settingsPanels, openProviders: openProviders, workspaceItems: workspaceItems };
}
function requestExtension(request) {
@@ -294,6 +374,118 @@
return Promise.resolve([result, '']);
}
+ function defaultEditorBundle() {
+ return [
+ '(function(){',
+ 'var DefaultEditor={',
+ 'mount:function(c,p,api){',
+ 'c.innerHTML="";',
+ 'c.className="de-root";',
+ 'var req=p.request||{};',
+ 'var path=req.path||"";',
+ 'var mode=req.mode||"view";',
+ 'var ctx=req.context||{};',
+ 'var isNotes=ctx.notesMode||ctx.isInsideNotesFolder;',
+ 'var ext=(req.extension||"").toLowerCase();',
+ 'var isMd=ext===".md"||ext===".markdown";',
+ 'var editorMode=isNotes?"notes-markdown":isMd?"generic-markdown":"text";',
+ 'c.setAttribute("data-editor-mode",editorMode);',
+ 'c.setAttribute("data-resource-path",path);',
+ 'c.setAttribute("data-request-mode",mode);',
+ 'var toolbar=document.createElement("div");',
+ 'toolbar.className="de-toolbar";',
+ 'var modeLabel=document.createElement("span");',
+ 'modeLabel.className="de-toolbar-mode";',
+ 'modeLabel.textContent=editorMode;',
+ 'toolbar.appendChild(modeLabel);',
+ 'var pathLabel=document.createElement("span");',
+ 'pathLabel.className="de-toolbar-context";',
+ 'pathLabel.textContent=path;',
+ 'toolbar.appendChild(pathLabel);',
+ 'if(isNotes){var badge=document.createElement("span");badge.className="de-notes-badge";badge.textContent="notes context";badge.setAttribute("data-notes-badge","");toolbar.appendChild(badge);}',
+ 'c.appendChild(toolbar);',
+ 'var content=document.createElement("div");',
+ 'content.className="de-editor-wrap";',
+ 'content.textContent="Loading...";',
+ 'c.appendChild(content);',
+ 'api.files.readText(path).then(function(text){',
+ 'content.textContent="";',
+ 'if(isMd){',
+ 'var preview=document.createElement("div");',
+ 'preview.className="de-preview";',
+ 'preview.setAttribute("data-preview","");',
+ 'preview.textContent=text;',
+ 'content.appendChild(preview);',
+ '}else{',
+ 'var ta=document.createElement("textarea");',
+ 'ta.className="de-textarea";',
+ 'ta.value=text;',
+ 'ta.setAttribute("data-editor-textarea","");',
+ 'content.appendChild(ta);',
+ '}',
+ '}).catch(function(err){',
+ 'content.textContent="Error: "+(err.message||err);',
+ '});',
+ '},',
+ 'unmount:function(c){c.innerHTML="";}',
+ '};',
+ 'window.VerstakPluginRegister("verstak.default-editor",{components:{DefaultEditor:DefaultEditor}});',
+ '})();'
+ ].join('\n');
+ }
+
+ function filesPluginBundle() {
+ return [
+ "(function(){",
+ "var FilesView={",
+ "mount:function(c,p,api){",
+ "c.innerHTML='';",
+ "c.className='files-root';",
+ "c.setAttribute('data-plugin-id','verstak.files');",
+ "var list=document.createElement('div');",
+ "list.className='files-list';",
+ "list.setAttribute('data-files-list','');",
+ "c.appendChild(list);",
+ "function load(){",
+ "list.textContent='Loading...';",
+ "api.files.list('').then(function(entries){",
+ "list.innerHTML='';",
+ "if(!entries||!entries.length){list.textContent='Empty folder';return;}",
+ "entries.forEach(function(e){",
+ "if(e.isHidden||e.isReserved)return;",
+ "var item=document.createElement('div');",
+ "item.className='files-item';",
+ "item.setAttribute('data-file-name',e.name);",
+ "item.setAttribute('data-file-type',e.type);",
+ "item.setAttribute('data-file-path',e.relativePath);",
+ "var icon=document.createElement('span');",
+ "icon.className='files-item-icon';",
+ "icon.textContent=e.type==='folder'?'[D]':'[F]';",
+ "var name=document.createElement('span');",
+ "name.className='files-item-name';",
+ "name.textContent=e.name;",
+ "item.appendChild(icon);",
+ "item.appendChild(name);",
+ "if(e.type!=='folder'){",
+ "item.addEventListener('dblclick',function(){",
+ "var ext=e.extension?'.'+e.extension:'';",
+ "var ctx={sourcePluginId:'verstak.files',sourceView:'files'};",
+ "api.workbench.openResource({kind:'vault-file',path:e.relativePath,mode:'view',extension:ext,context:ctx});",
+ "});",
+ "}",
+ "list.appendChild(item);",
+ "});",
+ "}).catch(function(err){list.textContent='Error: '+(err.message||err);});",
+ "}",
+ "load();",
+ "},",
+ "unmount:function(c){c.innerHTML='';}",
+ "};",
+ "window.VerstakPluginRegister('verstak.files',{components:{FilesView:FilesView}});",
+ "})();"
+ ].join('\n');
+ }
+
function platformTestBundle() {
return [
"(function(){",
@@ -448,6 +640,12 @@
if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(platformTestBundle());
}
+ if (pluginId === 'verstak.default-editor' && assetPath === 'frontend/dist/index.js') {
+ return Promise.resolve(defaultEditorBundle());
+ }
+ if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') {
+ return Promise.resolve(filesPluginBundle());
+ }
return Promise.resolve('');
},
GetPluginCapability: function (pluginId, capId) {
@@ -675,11 +873,10 @@
{
id: 'verstak.platform-test.markdown-diagnostic',
title: 'Platform Test Markdown Diagnostic',
- priority: 100,
+ priority: 10,
component: 'MarkdownDiagnosticProvider',
supports: [
- { kind: 'vault-file', extensions: ['.md', '.markdown'], contexts: ['generic-markdown', 'notes-markdown'] },
- { kind: 'vault-file', extensions: ['.txt', '.log'], mime: ['text/plain'], contexts: ['generic-text'] }
+ { kind: 'vault-file', extensions: ['.md', '.markdown'], contexts: ['generic-markdown', 'notes-markdown'] }
]
}
]
@@ -687,10 +884,85 @@
},
rootPath: '/tmp/verstak-test/plugins/platform-test',
error: ''
+ },
+ 'verstak.default-editor': {
+ status: 'loaded',
+ enabled: true,
+ manifest: {
+ schemaVersion: 1,
+ id: 'verstak.default-editor',
+ name: 'Default Editor',
+ version: '0.1.0',
+ apiVersion: '0.1.0',
+ description: 'Built-in text and markdown editor/viewer.',
+ source: 'official',
+ icon: 'edit',
+ provides: ['verstak/default-editor/v1'],
+ requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'],
+ permissions: ['files.read', 'files.write', 'workbench.open'],
+ frontend: { entry: 'frontend/dist/index.js' },
+ contributes: {
+ openProviders: [
+ {
+ id: 'verstak.default-editor.text',
+ title: 'Default Text Editor',
+ priority: 50,
+ component: 'DefaultEditor',
+ supports: [
+ { kind: 'vault-file', extensions: ['.txt', '.log', '.conf', '.ini', '.toml', '.yaml', '.yml', '.json', '.csv'], mime: ['text/plain', 'application/json'], contexts: ['generic-text'] }
+ ]
+ },
+ {
+ id: 'verstak.default-editor.markdown',
+ title: 'Default Markdown Editor',
+ priority: 50,
+ component: 'DefaultEditor',
+ supports: [
+ { kind: 'vault-file', extensions: ['.md', '.markdown'], contexts: ['generic-markdown'] }
+ ]
+ },
+ {
+ id: 'verstak.default-editor.notes-markdown',
+ title: 'Default Notes Markdown Editor',
+ priority: 50,
+ component: 'DefaultEditor',
+ supports: [
+ { kind: 'vault-file', extensions: ['.md', '.markdown'], contexts: ['notes-markdown'] }
+ ]
+ }
+ ]
+ }
+ },
+ rootPath: '/tmp/verstak-test/plugins/default-editor',
+ error: ''
+ },
+ 'verstak.files': {
+ status: 'loaded',
+ enabled: true,
+ manifest: {
+ schemaVersion: 1,
+ id: 'verstak.files',
+ name: 'Files',
+ version: '0.1.0',
+ apiVersion: '0.1.0',
+ description: 'Minimal vault file navigator.',
+ source: 'official',
+ icon: 'folder',
+ provides: ['verstak/files/v1'],
+ requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'],
+ permissions: ['files.read', 'files.write', 'workbench.open', 'ui.register'],
+ frontend: { entry: 'frontend/dist/index.js' },
+ contributes: {
+ views: [{ id: 'verstak.files.view', title: 'Files', icon: 'folder', component: 'FilesView' }],
+ workspaceItems: [{ id: 'verstak.files.workspace', title: 'Files', icon: 'folder', component: 'FilesView' }]
+ }
+ },
+ rootPath: '/tmp/verstak-test/plugins/files',
+ error: ''
}
};
vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
- vaultPluginState = { enabledPlugins: ['verstak.platform-test'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }] };
+ vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }] };
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
workbenchPreferences = {};
openedResources = [];
diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts
index 2b89c34..fdb9bbc 100755
--- a/frontend/wailsjs/go/api/App.d.ts
+++ b/frontend/wailsjs/go/api/App.d.ts
@@ -1,11 +1,11 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
+import {workbench} from '../models';
import {capability} from '../models';
import {api} from '../models';
import {permissions} from '../models';
import {plugin} from '../models';
import {files} from '../models';
-import {workbench} from '../models';
export function ArchiveWorkspaceNode(arg1:string):Promise;
@@ -19,12 +19,12 @@ export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise
export function DisablePlugin(arg1:string):Promise;
+export function EditWorkbenchResource(arg1:string,arg2:Record):Promise;
+
export function EnablePlugin(arg1:string):Promise;
export function ExecutePluginCommand(arg1:string,arg2:string,arg3:Record):Promise|string>;
-export function EditWorkbenchResource(arg1:string,arg2:Record):Promise;
-
export function GetAppSettings():Promise>;
export function GetCapabilities():Promise>;
@@ -49,12 +49,12 @@ export function GetVaultPluginState():Promise>;
export function GetVaultStatus():Promise>;
-export function GetWorkspaceTree():Promise>;
-
export function GetWorkbenchOpenedResources():Promise>;
export function GetWorkbenchPreferences():Promise;
+export function GetWorkspaceTree():Promise>;
+
export function ListPluginCapabilities(arg1:string):Promise|string>;
export function ListVaultFiles(arg1:string,arg2:string):Promise|string>;
diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js
index 1a762d2..c73da96 100755
--- a/frontend/wailsjs/go/api/App.js
+++ b/frontend/wailsjs/go/api/App.js
@@ -26,6 +26,10 @@ export function DisablePlugin(arg1) {
return window['go']['api']['App']['DisablePlugin'](arg1);
}
+export function EditWorkbenchResource(arg1, arg2) {
+ return window['go']['api']['App']['EditWorkbenchResource'](arg1, arg2);
+}
+
export function EnablePlugin(arg1) {
return window['go']['api']['App']['EnablePlugin'](arg1);
}
@@ -34,10 +38,6 @@ export function ExecutePluginCommand(arg1, arg2, arg3) {
return window['go']['api']['App']['ExecutePluginCommand'](arg1, arg2, arg3);
}
-export function EditWorkbenchResource(arg1, arg2) {
- return window['go']['api']['App']['EditWorkbenchResource'](arg1, arg2);
-}
-
export function GetAppSettings() {
return window['go']['api']['App']['GetAppSettings']();
}
@@ -86,10 +86,6 @@ export function GetVaultStatus() {
return window['go']['api']['App']['GetVaultStatus']();
}
-export function GetWorkspaceTree() {
- return window['go']['api']['App']['GetWorkspaceTree']();
-}
-
export function GetWorkbenchOpenedResources() {
return window['go']['api']['App']['GetWorkbenchOpenedResources']();
}
@@ -98,6 +94,10 @@ export function GetWorkbenchPreferences() {
return window['go']['api']['App']['GetWorkbenchPreferences']();
}
+export function GetWorkspaceTree() {
+ return window['go']['api']['App']['GetWorkspaceTree']();
+}
+
export function ListPluginCapabilities(arg1) {
return window['go']['api']['App']['ListPluginCapabilities'](arg1);
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index f0aa691..3894ca9 100755
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -1,15 +1,35 @@
export namespace api {
-
+
+ export class FlatWorkspaceItem {
+ pluginId: string;
+ id: string;
+ title: string;
+ icon?: string;
+ component: string;
+
+ static createFrom(source: any = {}) {
+ return new FlatWorkspaceItem(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.pluginId = source["pluginId"];
+ this.id = source["id"];
+ this.title = source["title"];
+ this.icon = source["icon"];
+ this.component = source["component"];
+ }
+ }
export class FlatOpenProviderSupport {
kind: string;
mime?: string[];
extensions?: string[];
contexts?: string[];
-
+
static createFrom(source: any = {}) {
return new FlatOpenProviderSupport(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.kind = source["kind"];
@@ -25,11 +45,11 @@ export namespace api {
priority?: number;
component: string;
supports: FlatOpenProviderSupport[];
-
+
static createFrom(source: any = {}) {
return new FlatOpenProvider(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
@@ -39,7 +59,7 @@ export namespace api {
this.component = source["component"];
this.supports = this.convertValues(source["supports"], FlatOpenProviderSupport);
}
-
+
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -65,11 +85,11 @@ export namespace api {
icon?: string;
view: string;
position?: number;
-
+
static createFrom(source: any = {}) {
return new FlatSidebarItem(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
@@ -86,11 +106,11 @@ export namespace api {
title: string;
icon?: string;
component: string;
-
+
static createFrom(source: any = {}) {
return new FlatSettingsPanel(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
@@ -106,11 +126,11 @@ export namespace api {
title: string;
icon?: string;
handler?: string;
-
+
static createFrom(source: any = {}) {
return new FlatCommand(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
@@ -126,11 +146,11 @@ export namespace api {
title: string;
icon?: string;
component: string;
-
+
static createFrom(source: any = {}) {
return new FlatView(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
@@ -146,11 +166,12 @@ export namespace api {
settingsPanels: FlatSettingsPanel[];
sidebarItems: FlatSidebarItem[];
openProviders: FlatOpenProvider[];
-
+ workspaceItems: FlatWorkspaceItem[];
+
static createFrom(source: any = {}) {
return new ContributionSummary(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.views = this.convertValues(source["views"], FlatView);
@@ -158,8 +179,9 @@ export namespace api {
this.settingsPanels = this.convertValues(source["settingsPanels"], FlatSettingsPanel);
this.sidebarItems = this.convertValues(source["sidebarItems"], FlatSidebarItem);
this.openProviders = this.convertValues(source["openProviders"], FlatOpenProvider);
+ this.workspaceItems = this.convertValues(source["workspaceItems"], FlatWorkspaceItem);
}
-
+
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -178,185 +200,27 @@ export namespace api {
return a;
}
}
-
-
-
-
-}
-
-export namespace workbench {
-
- export class OpenResourceContext {
- sourcePluginId?: string;
- sourceView?: string;
- isInsideNotesFolder?: boolean;
- notesScopePath?: string;
- notesMode?: boolean;
-
- static createFrom(source: any = {}) {
- return new OpenResourceContext(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.sourcePluginId = source["sourcePluginId"];
- this.sourceView = source["sourceView"];
- this.isInsideNotesFolder = source["isInsideNotesFolder"];
- this.notesScopePath = source["notesScopePath"];
- this.notesMode = source["notesMode"];
- }
- }
- export class OpenResourceRequest {
- kind: string;
- path: string;
- mode?: string;
- mime?: string;
- extension?: string;
- context?: OpenResourceContext;
-
- static createFrom(source: any = {}) {
- return new OpenResourceRequest(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.kind = source["kind"];
- this.path = source["path"];
- this.mode = source["mode"];
- this.mime = source["mime"];
- this.extension = source["extension"];
- this.context = this.convertValues(source["context"], OpenResourceContext);
- }
-
- 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 OpenResourceResult {
- status: string;
- providerId?: string;
- providerPluginId?: string;
- providerComponent?: string;
- request: OpenResourceRequest;
- message?: string;
-
- static createFrom(source: any = {}) {
- return new OpenResourceResult(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.status = source["status"];
- this.providerId = source["providerId"];
- this.providerPluginId = source["providerPluginId"];
- this.providerComponent = source["providerComponent"];
- this.request = this.convertValues(source["request"], OpenResourceRequest);
- this.message = source["message"];
- }
-
- 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 OpenedResource {
- id: string;
- providerId: string;
- providerPluginId: string;
- providerComponent: string;
- request: OpenResourceRequest;
- openedAt: string;
-
- static createFrom(source: any = {}) {
- return new OpenedResource(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.id = source["id"];
- this.providerId = source["providerId"];
- this.providerPluginId = source["providerPluginId"];
- this.providerComponent = source["providerComponent"];
- this.request = this.convertValues(source["request"], OpenResourceRequest);
- this.openedAt = source["openedAt"];
- }
-
- 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 Preferences {
- defaultTextEditorProvider?: string;
- defaultMarkdownEditorProvider?: string;
- defaultNotesMarkdownEditorProvider?: string;
-
- static createFrom(source: any = {}) {
- return new Preferences(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.defaultTextEditorProvider = source["defaultTextEditorProvider"];
- this.defaultMarkdownEditorProvider = source["defaultMarkdownEditorProvider"];
- this.defaultNotesMarkdownEditorProvider = source["defaultNotesMarkdownEditorProvider"];
- }
- }
+
+
+
+
+
+
}
export namespace capability {
-
+
export class Entry {
name: string;
description?: string;
pluginId: string;
status: string;
-
+
static createFrom(source: any = {}) {
return new Entry(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
@@ -369,7 +233,7 @@ export namespace capability {
}
export namespace files {
-
+
export class FileEntry {
name: string;
relativePath: string;
@@ -381,11 +245,11 @@ export namespace files {
isReserved: boolean;
canRead: boolean;
canWrite: boolean;
-
+
static createFrom(source: any = {}) {
return new FileEntry(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
@@ -413,11 +277,11 @@ export namespace files {
isReserved: boolean;
canRead: boolean;
canWrite: boolean;
-
+
static createFrom(source: any = {}) {
return new FileMetadata(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.relativePath = source["relativePath"];
@@ -436,11 +300,11 @@ export namespace files {
}
export class MoveOptions {
overwrite: boolean;
-
+
static createFrom(source: any = {}) {
return new MoveOptions(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.overwrite = source["overwrite"];
@@ -451,11 +315,11 @@ export namespace files {
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"];
@@ -467,11 +331,11 @@ export namespace files {
export class WriteOptions {
createIfMissing: boolean;
overwrite: boolean;
-
+
static createFrom(source: any = {}) {
return new WriteOptions(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.createIfMissing = source["createIfMissing"];
@@ -482,16 +346,16 @@ export namespace files {
}
export namespace permissions {
-
+
export class Entry {
name: string;
description: string;
dangerous: boolean;
-
+
static createFrom(source: any = {}) {
return new Entry(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
@@ -503,15 +367,15 @@ export namespace permissions {
}
export namespace plugin {
-
+
export class HealthCheckConfig {
type?: string;
timeout?: number;
-
+
static createFrom(source: any = {}) {
return new HealthCheckConfig(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
@@ -522,18 +386,18 @@ export namespace plugin {
type: string;
entry: Record;
healthCheck?: HealthCheckConfig;
-
+
static createFrom(source: any = {}) {
return new BackendConfig(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.entry = source["entry"];
this.healthCheck = this.convertValues(source["healthCheck"], HealthCheckConfig);
}
-
+
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -558,11 +422,11 @@ export namespace plugin {
icon?: string;
capability?: string;
handler?: string;
-
+
static createFrom(source: any = {}) {
return new ContributionAction(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -576,11 +440,11 @@ export namespace plugin {
id: string;
events?: string[];
handler: string;
-
+
static createFrom(source: any = {}) {
return new ContributionActivityProvider(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -594,11 +458,11 @@ export namespace plugin {
keybinding?: string;
icon?: string;
handler?: string;
-
+
static createFrom(source: any = {}) {
return new ContributionCommand(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -615,11 +479,11 @@ export namespace plugin {
group?: string;
capability?: string;
handler?: string;
-
+
static createFrom(source: any = {}) {
return new ContributionContextMenuEntry(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -630,15 +494,71 @@ export namespace plugin {
this.handler = source["handler"];
}
}
+ export class OpenProviderSupport {
+ kind: string;
+ mime?: string[];
+ extensions?: string[];
+ contexts?: string[];
+
+ static createFrom(source: any = {}) {
+ return new OpenProviderSupport(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.kind = source["kind"];
+ this.mime = source["mime"];
+ this.extensions = source["extensions"];
+ this.contexts = source["contexts"];
+ }
+ }
+ export class ContributionOpenProvider {
+ id: string;
+ title: string;
+ priority?: number;
+ component: string;
+ supports: OpenProviderSupport[];
+
+ static createFrom(source: any = {}) {
+ return new ContributionOpenProvider(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.title = source["title"];
+ this.priority = source["priority"];
+ this.component = source["component"];
+ this.supports = this.convertValues(source["supports"], OpenProviderSupport);
+ }
+
+ 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 ContributionSearchProvider {
id: string;
label: string;
handler: string;
-
+
static createFrom(source: any = {}) {
return new ContributionSearchProvider(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -651,11 +571,11 @@ export namespace plugin {
title: string;
component: string;
icon?: string;
-
+
static createFrom(source: any = {}) {
return new ContributionSettingsPanel(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -670,11 +590,11 @@ export namespace plugin {
icon?: string;
view: string;
position?: number;
-
+
static createFrom(source: any = {}) {
return new ContributionSidebarItem(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -689,11 +609,11 @@ export namespace plugin {
label: string;
position?: string;
handler?: string;
-
+
static createFrom(source: any = {}) {
return new ContributionStatusBarItem(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -707,11 +627,29 @@ export namespace plugin {
title: string;
icon?: string;
component: string;
-
+
static createFrom(source: any = {}) {
return new ContributionView(source);
}
-
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.title = source["title"];
+ this.icon = source["icon"];
+ this.component = source["component"];
+ }
+ }
+ export class ContributionWorkspaceItem {
+ id: string;
+ title: string;
+ icon?: string;
+ component: string;
+
+ static createFrom(source: any = {}) {
+ return new ContributionWorkspaceItem(source);
+ }
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@@ -731,11 +669,13 @@ export namespace plugin {
searchProviders?: ContributionSearchProvider[];
activityProviders?: ContributionActivityProvider[];
statusBarItems?: ContributionStatusBarItem[];
-
+ openProviders?: ContributionOpenProvider[];
+ workspaceItems?: ContributionWorkspaceItem[];
+
static createFrom(source: any = {}) {
return new Contributions(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.views = this.convertValues(source["views"], ContributionView);
@@ -748,8 +688,10 @@ export namespace plugin {
this.searchProviders = this.convertValues(source["searchProviders"], ContributionSearchProvider);
this.activityProviders = this.convertValues(source["activityProviders"], ContributionActivityProvider);
this.statusBarItems = this.convertValues(source["statusBarItems"], ContributionStatusBarItem);
+ this.openProviders = this.convertValues(source["openProviders"], ContributionOpenProvider);
+ this.workspaceItems = this.convertValues(source["workspaceItems"], ContributionWorkspaceItem);
}
-
+
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -771,26 +713,26 @@ export namespace plugin {
export class FrontendConfig {
entry: string;
style?: string;
-
+
static createFrom(source: any = {}) {
return new FrontendConfig(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.entry = source["entry"];
this.style = source["style"];
}
}
-
+
export class SyncConfig {
namespaces?: string[];
participate?: boolean;
-
+
static createFrom(source: any = {}) {
return new SyncConfig(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.namespaces = source["namespaces"];
@@ -799,11 +741,11 @@ export namespace plugin {
}
export class MigrationConfig {
path?: string;
-
+
static createFrom(source: any = {}) {
return new MigrationConfig(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
@@ -827,11 +769,11 @@ export namespace plugin {
migrations?: MigrationConfig;
contributes?: Contributions;
sync?: SyncConfig;
-
+
static createFrom(source: any = {}) {
return new Manifest(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.schemaVersion = source["schemaVersion"];
@@ -852,7 +794,7 @@ export namespace plugin {
this.contributes = this.convertValues(source["contributes"], Contributions);
this.sync = this.convertValues(source["sync"], SyncConfig);
}
-
+
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -871,18 +813,19 @@ export namespace plugin {
return a;
}
}
-
+
+
export class Plugin {
manifest: Manifest;
status: string;
error?: string;
enabled: boolean;
rootPath: string;
-
+
static createFrom(source: any = {}) {
return new Plugin(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.manifest = this.convertValues(source["manifest"], Manifest);
@@ -891,7 +834,7 @@ export namespace plugin {
this.enabled = source["enabled"];
this.rootPath = source["rootPath"];
}
-
+
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -912,3 +855,165 @@ export namespace plugin {
}
}
+
+export namespace workbench {
+
+ export class OpenResourceContext {
+ sourcePluginId?: string;
+ sourceView?: string;
+ isInsideNotesFolder?: boolean;
+ notesScopePath?: string;
+ notesMode?: boolean;
+
+ static createFrom(source: any = {}) {
+ return new OpenResourceContext(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.sourcePluginId = source["sourcePluginId"];
+ this.sourceView = source["sourceView"];
+ this.isInsideNotesFolder = source["isInsideNotesFolder"];
+ this.notesScopePath = source["notesScopePath"];
+ this.notesMode = source["notesMode"];
+ }
+ }
+ export class OpenResourceRequest {
+ kind: string;
+ path: string;
+ mode?: string;
+ mime?: string;
+ extension?: string;
+ context?: OpenResourceContext;
+
+ static createFrom(source: any = {}) {
+ return new OpenResourceRequest(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.kind = source["kind"];
+ this.path = source["path"];
+ this.mode = source["mode"];
+ this.mime = source["mime"];
+ this.extension = source["extension"];
+ this.context = this.convertValues(source["context"], OpenResourceContext);
+ }
+
+ 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 OpenResourceResult {
+ status: string;
+ providerId?: string;
+ providerPluginId?: string;
+ providerComponent?: string;
+ request: OpenResourceRequest;
+ message?: string;
+
+ static createFrom(source: any = {}) {
+ return new OpenResourceResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.status = source["status"];
+ this.providerId = source["providerId"];
+ this.providerPluginId = source["providerPluginId"];
+ this.providerComponent = source["providerComponent"];
+ this.request = this.convertValues(source["request"], OpenResourceRequest);
+ this.message = source["message"];
+ }
+
+ 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 OpenedResource {
+ id: string;
+ providerId: string;
+ providerPluginId: string;
+ providerComponent: string;
+ request: OpenResourceRequest;
+ openedAt: string;
+
+ static createFrom(source: any = {}) {
+ return new OpenedResource(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.providerId = source["providerId"];
+ this.providerPluginId = source["providerPluginId"];
+ this.providerComponent = source["providerComponent"];
+ this.request = this.convertValues(source["request"], OpenResourceRequest);
+ this.openedAt = source["openedAt"];
+ }
+
+ 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 Preferences {
+ defaultTextEditorProvider?: string;
+ defaultMarkdownEditorProvider?: string;
+ defaultNotesMarkdownEditorProvider?: string;
+
+ static createFrom(source: any = {}) {
+ return new Preferences(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.defaultTextEditorProvider = source["defaultTextEditorProvider"];
+ this.defaultMarkdownEditorProvider = source["defaultMarkdownEditorProvider"];
+ this.defaultNotesMarkdownEditorProvider = source["defaultNotesMarkdownEditorProvider"];
+ }
+ }
+
+}
+
diff --git a/internal/api/app.go b/internal/api/app.go
index 4361b85..35aa267 100644
--- a/internal/api/app.go
+++ b/internal/api/app.go
@@ -240,6 +240,14 @@ type FlatOpenProvider struct {
Supports []FlatOpenProviderSupport `json:"supports"`
}
+type FlatWorkspaceItem struct {
+ PluginID string `json:"pluginId"`
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Icon string `json:"icon,omitempty"`
+ Component string `json:"component"`
+}
+
// ContributionSummary aggregates all contribution types for the frontend.
type ContributionSummary struct {
Views []FlatView `json:"views"`
@@ -247,6 +255,7 @@ type ContributionSummary struct {
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
OpenProviders []FlatOpenProvider `json:"openProviders"`
+ WorkspaceItems []FlatWorkspaceItem `json:"workspaceItems"`
}
// buildContributionSummary creates a ContributionSummary from the registry.
@@ -259,6 +268,7 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
regPanels := r.SettingsPanels()
regSidebar := r.SidebarItems()
regOpenProviders := r.OpenProviders()
+ regWorkspaceItems := r.WorkspaceItems()
views := make([]FlatView, len(regViews))
for i, v := range regViews {
@@ -291,7 +301,11 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
Supports: supports,
}
}
- return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar, OpenProviders: openProviders}
+ workspaceItems := make([]FlatWorkspaceItem, len(regWorkspaceItems))
+ for i, v := range regWorkspaceItems {
+ workspaceItems[i] = FlatWorkspaceItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
+ }
+ return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar, OpenProviders: openProviders, WorkspaceItems: workspaceItems}
}
// GetContributions returns all registered contributions flattened for the frontend.
diff --git a/internal/api/app_test.go b/internal/api/app_test.go
index 7d930b1..9fe8c13 100644
--- a/internal/api/app_test.go
+++ b/internal/api/app_test.go
@@ -668,6 +668,25 @@ func TestWorkbenchOpenResourceRequiresPermission(t *testing.T) {
}
}
+func TestWorkbenchDisabledPluginProviderExcluded(t *testing.T) {
+ app := newBridgeTestApp(t)
+
+ result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{
+ "kind": "vault-file",
+ "path": "Docs/readme.md",
+ "extension": ".md",
+ })
+ if errStr != "" {
+ t.Fatalf("OpenWorkbenchResource: %s", errStr)
+ }
+ if result.ProviderPluginID != "bridge.plugin" {
+ t.Fatalf("expected bridge.plugin provider, got providerPluginId=%q", result.ProviderPluginID)
+ }
+ if result.ProviderID == "disabled.markdown" {
+ t.Fatal("disabled plugin provider should be excluded from selection")
+ }
+}
+
func TestPluginBridgeSettingsRequireLoadedPluginAndStoragePermission(t *testing.T) {
app := newBridgeTestApp(t)
diff --git a/internal/core/contribution/registry.go b/internal/core/contribution/registry.go
index f20b908..d90340c 100644
--- a/internal/core/contribution/registry.go
+++ b/internal/core/contribution/registry.go
@@ -23,6 +23,7 @@ type Registry struct {
activityProviders []ContributionActivityProvider
statusBarItems []ContributionStatusBarItem
openProviders []ContributionOpenProvider
+ workspaceItems []ContributionWorkspaceItem
}
// ContributionPointType defines the type of contribution point.
@@ -40,6 +41,7 @@ const (
PointActivity ContributionPointType = "activityProviders"
PointStatusBar ContributionPointType = "statusBarItems"
PointOpenProviders ContributionPointType = "openProviders"
+ PointWorkspaceItems ContributionPointType = "workspaceItems"
)
// ListByPoint returns all contributions for a given point type.
@@ -93,6 +95,10 @@ func (r *Registry) ListByPoint(point ContributionPointType) []interface{} {
for _, v := range r.openProviders {
result = append(result, v)
}
+ case PointWorkspaceItems:
+ for _, v := range r.workspaceItems {
+ result = append(result, v)
+ }
}
return result
}
@@ -147,6 +153,12 @@ type ContributionOpenProvider struct {
Item plugin.ContributionOpenProvider `json:"item"`
}
+// ContributionWorkspaceItem is a workspace tool contribution.
+type ContributionWorkspaceItem struct {
+ PluginID string `json:"pluginId"`
+ Item plugin.ContributionWorkspaceItem `json:"item"`
+}
+
// NewRegistry creates a new contribution registry.
func NewRegistry() *Registry {
return &Registry{}
@@ -171,6 +183,7 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
+ r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
for _, item := range c.Views {
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
@@ -205,6 +218,9 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
for _, item := range c.OpenProviders {
r.openProviders = append(r.openProviders, ContributionOpenProvider{PluginID: pluginID, Item: item})
}
+ for _, item := range c.WorkspaceItems {
+ r.workspaceItems = append(r.workspaceItems, ContributionWorkspaceItem{PluginID: pluginID, Item: item})
+ }
}
// Unregister removes all contributions from a plugin.
@@ -223,6 +239,7 @@ func (r *Registry) Unregister(pluginID string) {
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
+ r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
}
// Getters — sorted for deterministic display.
@@ -304,6 +321,20 @@ func (r *Registry) OpenProviders() []ContributionOpenProvider {
return result
}
+func (r *Registry) WorkspaceItems() []ContributionWorkspaceItem {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ result := make([]ContributionWorkspaceItem, len(r.workspaceItems))
+ copy(result, r.workspaceItems)
+ sort.Slice(result, func(i, j int) bool {
+ if result[i].PluginID != result[j].PluginID {
+ return result[i].PluginID < result[j].PluginID
+ }
+ return result[i].Item.ID < result[j].Item.ID
+ })
+ return result
+}
+
// ─── Remove helpers ─────────────────────────────────────────
func removeViews(items []ContributionView, pluginID string) []ContributionView {
@@ -405,3 +436,13 @@ func removeOpenProviders(items []ContributionOpenProvider, pluginID string) []Co
}
return result
}
+
+func removeWorkspaceItems(items []ContributionWorkspaceItem, pluginID string) []ContributionWorkspaceItem {
+ var result []ContributionWorkspaceItem
+ for _, item := range items {
+ if item.PluginID != pluginID {
+ result = append(result, item)
+ }
+ }
+ return result
+}
diff --git a/internal/core/plugin/plugin.go b/internal/core/plugin/plugin.go
index c0fdfea..c082dae 100644
--- a/internal/core/plugin/plugin.go
+++ b/internal/core/plugin/plugin.go
@@ -68,6 +68,7 @@ type Contributions struct {
ActivityProviders []ContributionActivityProvider `json:"activityProviders,omitempty"`
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
+ WorkspaceItems []ContributionWorkspaceItem `json:"workspaceItems,omitempty"`
}
// ContributionView represents a view contribution.
@@ -162,6 +163,14 @@ type ContributionOpenProvider struct {
Supports []OpenProviderSupport `json:"supports"`
}
+// ContributionWorkspaceItem represents a workspace tool contribution.
+type ContributionWorkspaceItem struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Icon string `json:"icon,omitempty"`
+ Component string `json:"component"`
+}
+
// SyncConfig describes plugin sync configuration.
type SyncConfig struct {
Namespaces []string `json:"namespaces,omitempty"`
diff --git a/internal/core/workbench/routing_test.go b/internal/core/workbench/routing_test.go
index 1005b24..c07536b 100644
--- a/internal/core/workbench/routing_test.go
+++ b/internal/core/workbench/routing_test.go
@@ -205,6 +205,187 @@ func TestOpenResourceReturnsNoProviderFallback(t *testing.T) {
}
}
+func TestSelectProviderUsesTextPreference(t *testing.T) {
+ r := NewRouter(Preferences{
+ DefaultTextEditorProvider: "custom.text-editor",
+ })
+ providers := []contribution.ContributionOpenProvider{
+ provider("official.editor", "builtin.text", 100, "BuiltinText", plugin.OpenProviderSupport{
+ Kind: "vault-file",
+ Extensions: []string{".txt"},
+ Contexts: []string{ContextGenericText},
+ }),
+ provider("custom.editor", "custom.text-editor", 10, "CustomText", plugin.OpenProviderSupport{
+ Kind: "vault-file",
+ Extensions: []string{".txt"},
+ Contexts: []string{ContextGenericText},
+ }),
+ }
+
+ selected, err := r.SelectProvider(OpenResourceRequest{
+ Kind: "vault-file",
+ Path: "Docs/todo.txt",
+ Mode: "view",
+ }, providers)
+ if err != nil {
+ t.Fatalf("SelectProvider: %v", err)
+ }
+ if selected.Item.ID != "custom.text-editor" {
+ t.Fatalf("provider = %q, want custom.text-editor", selected.Item.ID)
+ }
+}
+
+func TestSelectProviderUsesMarkdownPreference(t *testing.T) {
+ r := NewRouter(Preferences{
+ DefaultMarkdownEditorProvider: "community.markdown-editor",
+ })
+ providers := []contribution.ContributionOpenProvider{
+ provider("official.editor", "builtin.markdown", 100, "BuiltinMarkdown", plugin.OpenProviderSupport{
+ Kind: "vault-file",
+ Extensions: []string{".md"},
+ Contexts: []string{ContextGenericMarkdown},
+ }),
+ provider("community.editor", "community.markdown-editor", 10, "CommunityMarkdown", plugin.OpenProviderSupport{
+ Kind: "vault-file",
+ Extensions: []string{".md"},
+ Contexts: []string{ContextGenericMarkdown},
+ }),
+ }
+
+ selected, err := r.SelectProvider(OpenResourceRequest{
+ Kind: "vault-file",
+ Path: "Docs/readme.md",
+ Mode: "view",
+ }, providers)
+ if err != nil {
+ t.Fatalf("SelectProvider: %v", err)
+ }
+ if selected.Item.ID != "community.markdown-editor" {
+ t.Fatalf("provider = %q, want community.markdown-editor", selected.Item.ID)
+ }
+}
+
+func TestSelectProviderMatchesMime(t *testing.T) {
+ r := NewRouter(Preferences{})
+ providers := []contribution.ContributionOpenProvider{
+ provider("image.plugin", "image.viewer", 10, "ImageViewer", plugin.OpenProviderSupport{
+ Kind: "vault-file",
+ Mime: []string{"image/png", "image/jpeg"},
+ }),
+ }
+
+ selected, err := r.SelectProvider(OpenResourceRequest{
+ Kind: "vault-file",
+ Path: "Photos/screenshot.png",
+ Extension: ".png",
+ Mime: "image/png",
+ Mode: "view",
+ }, providers)
+ if err != nil {
+ t.Fatalf("SelectProvider: %v", err)
+ }
+ if selected.Item.ID != "image.viewer" {
+ t.Fatalf("provider = %q, want image.viewer", selected.Item.ID)
+ }
+}
+
+func TestSelectProviderExtensionCaseInsensitive(t *testing.T) {
+ r := NewRouter(Preferences{})
+ providers := []contribution.ContributionOpenProvider{
+ provider("editor.plugin", "md.editor", 10, "MDEditor", plugin.OpenProviderSupport{
+ Kind: "vault-file",
+ Extensions: []string{".md"},
+ }),
+ }
+
+ tests := []struct {
+ name string
+ ext string
+ path string
+ }{
+ {"uppercase .MD", ".MD", "Docs/README.MD"},
+ {"mixed case .Md", ".Md", "Docs/Notes.Md"},
+ {"lowercase .md", ".md", "Docs/readme.md"},
+ {"markdown extension uppercase", ".MD", "Docs/guide.MD"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ selected, err := r.SelectProvider(OpenResourceRequest{
+ Kind: "vault-file",
+ Path: tt.path,
+ Extension: tt.ext,
+ Mode: "view",
+ }, providers)
+ if err != nil {
+ t.Fatalf("SelectProvider: %v", err)
+ }
+ if selected.Item.ID != "md.editor" {
+ t.Fatalf("provider = %q, want md.editor for ext %s", selected.Item.ID, tt.ext)
+ }
+ })
+ }
+}
+
+func TestSelectProviderMultipleSupportsEntries(t *testing.T) {
+ r := NewRouter(Preferences{})
+ providers := []contribution.ContributionOpenProvider{
+ provider("editor.plugin", "multi.editor", 10, "MultiEditor", plugin.OpenProviderSupport{
+ Kind: "vault-file",
+ Extensions: []string{".md"},
+ Contexts: []string{ContextGenericMarkdown},
+ }, plugin.OpenProviderSupport{
+ Kind: "vault-file",
+ Extensions: []string{".txt"},
+ Contexts: []string{ContextGenericText},
+ }),
+ }
+
+ t.Run("matches markdown entry", func(t *testing.T) {
+ selected, err := r.SelectProvider(OpenResourceRequest{
+ Kind: "vault-file",
+ Path: "Docs/readme.md",
+ Mode: "view",
+ }, providers)
+ if err != nil {
+ t.Fatalf("SelectProvider: %v", err)
+ }
+ if selected.Item.ID != "multi.editor" {
+ t.Fatalf("provider = %q, want multi.editor", selected.Item.ID)
+ }
+ })
+
+ t.Run("matches text entry", func(t *testing.T) {
+ selected, err := r.SelectProvider(OpenResourceRequest{
+ Kind: "vault-file",
+ Path: "Docs/todo.txt",
+ Mode: "view",
+ }, providers)
+ if err != nil {
+ t.Fatalf("SelectProvider: %v", err)
+ }
+ if selected.Item.ID != "multi.editor" {
+ t.Fatalf("provider = %q, want multi.editor", selected.Item.ID)
+ }
+ })
+}
+
+func TestSelectProviderKindMismatch(t *testing.T) {
+ r := NewRouter(Preferences{})
+ _, err := r.SelectProvider(OpenResourceRequest{
+ Kind: "http-url",
+ Path: "https://example.com/file.md",
+ Mode: "view",
+ }, []contribution.ContributionOpenProvider{
+ provider("editor.plugin", "vault.editor", 10, "VaultEditor", plugin.OpenProviderSupport{
+ Kind: "vault-file",
+ Extensions: []string{".md"},
+ }),
+ })
+ if err == nil {
+ t.Fatal("expected no provider for http-url kind with vault-file provider")
+ }
+}
+
func TestDetermineContextName(t *testing.T) {
tests := []struct {
name string