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} +
+ {currentNode.title} + {currentNode.type} +
+ + {#if workspaceTools.length > 0} +
+ {#each workspaceTools as tool (tool.id + tool.pluginId)} +
+
+ {tool.title || tool.id} + {tool.pluginId} +
+
+ +
+
+ {/each} +
+ {: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