@dolphy-app/extension-sdk 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  # Recipe: a command and a panel
2
2
 
3
3
  A command appears in the command palette and runs your code; a panel is a
4
- screen in an isolated frame that the command can open. This recipe is the
5
- `command-panel` template
4
+ screen the command can open: a Vue single-file component the app draws inside
5
+ its own window. This recipe is the `command-panel` template
6
6
  (`npx @dolphy-app/create-extension <dir> --id acme.hello --template command-panel`).
7
7
  The files below are exactly what the generator writes for the id `acme.hello`.
8
8
  See [quick-start.md](quick-start.md) for the commands.
@@ -20,190 +20,337 @@ File `extension.json` (command-panel):
20
20
  "name": "Hello panel",
21
21
  "description": "Palette commands that greet the learner and open a small panel.",
22
22
  "author": "your-github-login",
23
- "tags": ["productivity"],
24
- "contributes": {
25
- "commands": [
26
- { "id": "acme.hello.hello", "title": "Say hello", "category": "Hello" },
27
- { "id": "acme.hello.open", "title": "Open the hello panel", "category": "Hello" },
28
- { "id": "acme.hello.data", "title": "Hello panel data", "palette": false }
29
- ],
30
- "panels": [{ "id": "acme.hello.view", "title": "Hello" }]
31
- }
23
+ "tags": ["productivity"]
32
24
  }
33
25
  ```
34
26
 
35
- - `commands[]`: `title` and `category` are what the palette shows. A command
36
- with `"palette": false` is hidden from the palette: it is a handler only your
37
- panel calls.
38
- - `panels[]` declares the screen; its module defaults to `./panel.mjs`, which
39
- the build writes.
40
- - No permissions: commands, panels and `notify` need none.
27
+ The commands and the panel are not in the manifest: the two parts register
28
+ them. `dolphy-ext build` writes `main` and `client` into the built manifest.
41
29
 
42
- ## The code
30
+ ## The server part
43
31
 
44
32
  File `src/index.ts` (command-panel):
45
33
 
46
34
  ```ts
47
- import {
48
- defineExtension,
49
- defineExtensionPanel,
50
- notify,
51
- openPanel,
52
- } from '@dolphy-app/extension-sdk';
53
- import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
54
-
55
- // extension code: `host` runs in the extension process of the app
56
- // the ids come from extension.json: a misspelt id or a declared id without a
57
- // handler fails `pnpm typecheck`
58
- export const host = defineExtension({
59
- commands: {
60
- // palette command: shows a notification
61
- 'acme.hello.hello': (args) => {
35
+ export { client } from './client.ts';
36
+ export { server } from './server.ts';
37
+ ```
38
+
39
+ File `src/server.ts` (command-panel):
40
+
41
+ ```ts
42
+ import { defineServer, notify, openPanel } from '@dolphy-app/extension-sdk';
43
+
44
+ // runs in the extension host: every call registers a contribution
45
+ export const server = defineServer((s) => {
46
+ // palette command: shows a notification
47
+ s.registerCommand({
48
+ id: 'acme.hello.hello',
49
+ title: { en: 'Say hello', ru: 'Поздороваться' },
50
+ category: 'Hello',
51
+ run: (args) => {
62
52
  const name = typeof args === 'string' ? args : 'world';
63
53
  return notify(`Hello, ${name}!`);
64
54
  },
65
- // palette command: opens the panel with properties
66
- 'acme.hello.open': () => openPanel('acme.hello.view', { name: 'Dolphy' }),
67
- // hidden from the palette (palette: false): the panel asks for data
68
- 'acme.hello.data': () => ({ message: 'Hello from acme.hello' }),
69
- },
55
+ });
56
+
57
+ // palette command: opens the panel (registered by the client) with properties
58
+ s.registerCommand({
59
+ id: 'acme.hello.open',
60
+ title: { en: 'Open the hello panel', ru: 'Открыть панель' },
61
+ category: 'Hello',
62
+ run: () => openPanel('acme.hello.view', { name: 'Dolphy' }),
63
+ });
64
+
65
+ // hidden from the palette (palette: false): the panel asks for data
66
+ s.registerCommand({
67
+ id: 'acme.hello.data',
68
+ title: 'Hello panel data',
69
+ palette: false,
70
+ run: () => ({ message: 'Hello from acme.hello' }),
71
+ });
70
72
  });
73
+ ```
71
74
 
72
- // the panel runs in an isolated frame of the app window: no network, no
73
- // window.dolphy; the only way out is `ctx.call` to the commands above
74
- export const panels = {
75
- 'acme.hello.view': defineExtensionPanel({
76
- async mount(container, ctx) {
77
- const doc = container.ownerDocument;
78
- const title = doc.createElement('h2');
79
- const line = doc.createElement('p');
80
- container.append(title, line);
81
- const name = (props: unknown): string =>
82
- typeof props === 'object' && props !== null && 'name' in props
83
- ? String(props.name)
84
- : 'world';
85
- title.textContent = `Hello, ${name(ctx.props)}!`;
86
- // the app opens the panel again with new properties: redraw the title
87
- ctx.signal.addEventListener(
88
- 'abort',
89
- ctx.onProps((props) => {
90
- title.textContent = `Hello, ${name(props)}!`;
91
- }),
92
- );
93
- const data = (await ctx.call('acme.hello.data')) as { message: string };
94
- line.textContent = data.message;
95
- },
96
- }),
97
- } satisfies ExtensionPanels;
75
+ - `server.registerCommand({ id, title, run })` adds a command. `title` and
76
+ `category` are what the palette shows (`LocalizedText`: a string or
77
+ `{ en, ru }`). A command with `palette: false` is hidden from the palette: it
78
+ is a handler only your panel calls.
79
+ - `run(args)` returns what the app does next: `notify(text)` shows a
80
+ notification, `openPanel(id, props)` opens a panel with properties, any JSON
81
+ value is data for the caller, nothing is fine. `args` is whatever the caller
82
+ passes, so check its type. A handler has 10 seconds.
83
+ - Optional fields of a command: `description`, `icon`, `keybindings` and `when`
84
+ (see [visibility conditions](recipe-when-dependencies.md)).
85
+
86
+ ## The client part
87
+
88
+ File `src/client.ts` (command-panel):
89
+
90
+ ```ts
91
+ import { defineClient } from '@dolphy-app/extension-sdk';
92
+ import StatusPanel from './StatusPanel.vue';
93
+
94
+ // runs in the app window: the panel is a Vue component the app draws
95
+ export const client = defineClient((c) => {
96
+ c.addPanel({
97
+ id: 'acme.hello.view',
98
+ title: { en: 'Hello', ru: 'Привет' },
99
+ component: StatusPanel,
100
+ });
101
+ });
102
+ ```
103
+
104
+ File `src/StatusPanel.vue` (command-panel):
105
+
106
+ ```vue
107
+ <script setup lang="ts">
108
+ import { usePanel } from '@dolphy-app/extension-sdk/client';
109
+ import { computed, ref } from 'vue';
110
+
111
+ // `usePanel()` gives the panel the properties it was opened with and `call`
112
+ // for the commands of the extension
113
+ const panel = usePanel();
114
+ const message = ref('');
115
+ // the app opens the panel again with new properties: `panel.props` is
116
+ // reactive, the title follows it
117
+ const name = computed(() => {
118
+ const { props } = panel;
119
+ return typeof props === 'object' && props !== null && 'name' in props
120
+ ? String(props.name)
121
+ : 'world';
122
+ });
123
+
124
+ const load = async () => {
125
+ const data = await panel.call('acme.hello.data');
126
+ message.value = (data as { message: string }).message;
127
+ };
128
+ void load();
129
+ </script>
130
+
131
+ <template>
132
+ <section class="status-panel">
133
+ <h2>Hello, {{ name }}!</h2>
134
+ <p>{{ message }}</p>
135
+ <v-btn color="primary" @click="load">Reload</v-btn>
136
+ </section>
137
+ </template>
138
+
139
+ <style scoped>
140
+ .status-panel {
141
+ padding: 16px;
142
+ }
143
+ </style>
98
144
  ```
99
145
 
100
- - `commands` maps every declared id to a handler. The result is what the app
101
- does next: `notify(text)` shows a notification, `openPanel(id, props)` opens a
102
- panel with properties, any JSON value is data for the caller, nothing is fine.
103
- `args` is whatever the caller passes, so check its type.
104
- - A panel is `defineExtensionPanel({ mount(container, ctx) })`. `ctx` has
105
- `panelId`, `props`, `signal` (aborted when the panel closes), `onProps(listener)`
106
- for new properties when the command opens the panel again, and
107
- `ctx.call(commandId, args)`.
108
- - A panel runs in an isolated frame with no network and no access to the app;
109
- `ctx.call` to a declared command is the only way out. That is why the data
110
- command exists.
111
- - The build writes `host` to `main.mjs` and `panels` to `panel.mjs`.
146
+ - `client.addPanel({ id, title, component })` adds the screen and its entry in
147
+ the sidebar menu. The component is a Vue component, here a single-file
148
+ component (`.vue`), or a `Mountable` that draws with another framework (see
149
+ [recipe-mountable.md](recipe-mountable.md) and [recipe-react.md](recipe-react.md)).
150
+ `vue` is the app's own instance, so the panel shares its theme and language.
151
+ `openPanel('<id>', props)` of a command opens it.
152
+ - `header: false` (app 0.6.0 and later) removes the app's page header (back
153
+ button, title, extension id): the panel fills the page and draws its own
154
+ `<h1>`. The app moves focus to the panel container, labelled with `title`.
155
+ - A `.vue` file is built as it is: `<script setup lang="ts">`, `<template>`
156
+ and `<style>`. Vuetify components are written as tags (`<v-btn>`,
157
+ `<v-card>`) and Vuetify directives as `v-ripple`, with no import: the build
158
+ turns the ones a template uses into imports from the app's own Vuetify, so
159
+ the theme and the language of the app apply and the bundle does not carry
160
+ Vuetify. A `.vue` file in the server part is a build error.
161
+ - A `<style>` block is not tied to the panel: when `client.mjs` loads, the
162
+ styles of all its components go into one `<style data-dolphy-ext="<extension
163
+ id>">` tag of the window document, so a plain `<style>` reaches the whole
164
+ window. Write `<style scoped>`, as the template does. `<style module>` is
165
+ not supported.
166
+ - `pnpm typecheck` runs `vue-tsc --noEmit`: it checks the `<script setup
167
+ lang="ts">` and the `<template>` of a `.vue` file, which plain `tsc` does not
168
+ look into, and knows `.vue` imports without a `declare module` shim.
169
+ - Inside the component `usePanel()` from `@dolphy-app/extension-sdk/client`
170
+ returns the handle: `panelId`, the reactive `props` the panel was opened with
171
+ (a repeated `openPanel` with new properties updates them in place, so a
172
+ `computed` over `panel.props` follows), the reactive `context`
173
+ (`{ courseId }`) and `call(commandId, args)`.
174
+ - The code of a panel runs in the app window, and `call` is how it reaches the
175
+ commands of the server part, including those with `palette: false`. That is
176
+ why the data command exists.
177
+ - `client.addCommand({ id, title, run })` adds a command whose handler runs in
178
+ the window instead; `run` takes no arguments and returns nothing.
179
+ - The build writes `server` to `main.mjs` and `client` to `client.mjs`.
112
180
 
113
181
  ## The tests
114
182
 
183
+ File `vitest.config.ts` (command-panel):
184
+
185
+ ```ts
186
+ import vue from '@vitejs/plugin-vue';
187
+ import { defineConfig } from 'vitest/config';
188
+
189
+ export default defineConfig({ plugins: [vue()] });
190
+ ```
191
+
115
192
  File `test/index.test.ts` (command-panel):
116
193
 
117
194
  ```ts
118
195
  // @vitest-environment happy-dom
196
+ import { PANEL_HANDLE_KEY } from '@dolphy-app/extension-sdk';
197
+ import type { JsonValue, PanelHandle } from '@dolphy-app/extension-sdk';
119
198
  import {
120
- loadCommands,
121
- loadPanel,
199
+ createTestClient,
200
+ createTestServer,
122
201
  } from '@dolphy-app/extension-sdk/testing';
123
202
  import { afterEach, describe, expect, it } from 'vitest';
124
- import { host, panels } from '../src/index.ts';
203
+ import { createApp, defineComponent, h, nextTick, shallowReactive } from 'vue';
204
+ import { client, server } from '../src/index.ts';
205
+ import StatusPanel from '../src/StatusPanel.vue';
125
206
 
126
207
  const disposables: { dispose(): unknown }[] = [];
127
208
  afterEach(async () => {
128
209
  await Promise.all(disposables.splice(0).map((item) => item.dispose()));
129
210
  });
130
211
 
131
- const load = async () => {
132
- const commands = await loadCommands(host, {
133
- declaredCommands: ['acme.hello.hello', 'acme.hello.open', 'acme.hello.data'],
134
- declaredPanels: ['acme.hello.view'],
135
- });
136
- disposables.push(commands);
137
- return commands;
212
+ const start = async () => {
213
+ const running = await createTestServer(server, { extensionId: 'acme.hello' });
214
+ disposables.push(running);
215
+ return running;
138
216
  };
139
217
 
140
- describe('acme.hello: commands', () => {
218
+ describe('acme.hello: server', () => {
141
219
  it('hello greets the name from the arguments, "world" without them', async () => {
142
- const commands = await load();
143
- expect(await commands.run('acme.hello.hello', 'Ada')).toEqual({
220
+ const running = await start();
221
+ expect(await running.commands.run('acme.hello.hello', 'Ada')).toEqual({
144
222
  kind: 'notify',
145
223
  text: 'Hello, Ada!',
146
224
  });
147
- expect(await commands.run('acme.hello.hello')).toEqual({
225
+ expect(await running.commands.run('acme.hello.hello')).toEqual({
148
226
  kind: 'notify',
149
227
  text: 'Hello, world!',
150
228
  });
151
229
  });
152
230
 
153
231
  it('open asks the app to open the panel with properties', async () => {
154
- const commands = await load();
155
- expect(await commands.run('acme.hello.open')).toEqual({
232
+ const running = await start();
233
+ expect(await running.commands.run('acme.hello.open')).toEqual({
156
234
  kind: 'openPanel',
157
235
  panelId: 'acme.hello.view',
158
236
  props: { name: 'Dolphy' },
159
237
  });
160
238
  });
161
239
 
162
- it('data returns what the panel shows', async () => {
163
- const commands = await load();
164
- expect(await commands.run('acme.hello.data')).toEqual({
240
+ it('data returns what the panel shows and stays out of the palette', async () => {
241
+ const running = await start();
242
+ expect(await running.commands.run('acme.hello.data')).toEqual({
165
243
  kind: 'data',
166
244
  value: { message: 'Hello from acme.hello' },
167
245
  });
246
+ const hidden = running.registration.commands.find(
247
+ (command) => command.id === 'acme.hello.data',
248
+ );
249
+ expect(hidden?.palette).toBe(false);
168
250
  });
169
251
  });
170
252
 
253
+ describe('acme.hello: client', () => {
254
+ it('adds the panel that the open command points to', async () => {
255
+ const running = await createTestClient(client, { extensionId: 'acme.hello' });
256
+ disposables.push(running);
257
+ expect(running.panels.map((panel) => panel.id)).toEqual(['acme.hello.view']);
258
+ expect(running.panels[0]?.component).toBe(StatusPanel);
259
+ });
260
+ });
261
+
262
+ // the app draws `<v-btn>` with its Vuetify; the test gives the panel a plain button
263
+ const VBtn = defineComponent({
264
+ setup: (_props, { slots }) => () => h('button', slots['default']?.()),
265
+ });
266
+
267
+ // draws the panel the way the app does: the handle is provided to the component
268
+ const mountPanel = async (
269
+ props: JsonValue | undefined,
270
+ call: PanelHandle['call'],
271
+ ) => {
272
+ const handle = shallowReactive({
273
+ panelId: 'acme.hello.view',
274
+ props,
275
+ context: { courseId: null },
276
+ call,
277
+ });
278
+ const host = document.createElement('div');
279
+ document.body.append(host);
280
+ const app = createApp({ render: () => h(StatusPanel) });
281
+ app.component('v-btn', VBtn);
282
+ app.provide(PANEL_HANDLE_KEY, handle);
283
+ app.mount(host);
284
+ disposables.push({
285
+ dispose: () => {
286
+ app.unmount();
287
+ host.remove();
288
+ },
289
+ });
290
+ // the panel asks a command: wait for the reply, then for the redraw
291
+ const settle = async () => {
292
+ await new Promise((resolve) => setTimeout(resolve, 0));
293
+ await nextTick();
294
+ };
295
+ await settle();
296
+ return {
297
+ host,
298
+ reopen: async (next: JsonValue) => {
299
+ handle.props = next;
300
+ await settle();
301
+ },
302
+ };
303
+ };
304
+
171
305
  describe('acme.hello: panel', () => {
172
306
  it('shows the data command reply and follows new properties', async () => {
173
- const panel = await loadPanel(panels, 'acme.hello.view', {
174
- props: { name: 'Ada' },
175
- call: () => ({ message: 'Hello from the test' }),
307
+ const calls: string[] = [];
308
+ const panel = await mountPanel({ name: 'Ada' }, async (commandId) => {
309
+ calls.push(commandId);
310
+ return { message: 'Hello from the test' };
176
311
  });
177
- disposables.push(panel);
178
- expect(panel.container.querySelector('h2')?.textContent).toBe('Hello, Ada!');
179
- expect(panel.container.querySelector('p')?.textContent).toBe(
312
+ expect(panel.host.querySelector('h2')?.textContent).toBe('Hello, Ada!');
313
+ expect(panel.host.querySelector('p')?.textContent).toBe(
180
314
  'Hello from the test',
181
315
  );
182
- expect(panel.calls).toEqual([{ commandId: 'acme.hello.data', args: undefined }]);
316
+ expect(calls).toEqual(['acme.hello.data']);
183
317
 
184
- panel.setProps({ name: 'Grace' });
185
- expect(panel.container.querySelector('h2')?.textContent).toBe(
186
- 'Hello, Grace!',
187
- );
318
+ await panel.reopen({ name: 'Grace' });
319
+ expect(panel.host.querySelector('h2')?.textContent).toBe('Hello, Grace!');
188
320
  });
189
321
 
190
- it('stops listening for properties when the panel closes', async () => {
191
- const panel = await loadPanel(panels, 'acme.hello.view', {
192
- call: () => ({ message: 'x' }),
322
+ it('asks the data command again when the button is pressed', async () => {
323
+ const calls: string[] = [];
324
+ const panel = await mountPanel(undefined, async (commandId) => {
325
+ calls.push(commandId);
326
+ return { message: 'x' };
193
327
  });
194
- const heading = panel.container.querySelector('h2');
195
- panel.dispose();
196
- expect(panel.aborted).toBe(true);
197
- panel.setProps({ name: 'Late' });
198
- expect(heading?.textContent).toBe('Hello, world!');
328
+ panel.host.querySelector('button')?.click();
329
+ await panel.reopen({});
330
+ expect(calls).toEqual(['acme.hello.data', 'acme.hello.data']);
331
+ });
332
+
333
+ it('greets the world when it is opened without properties', async () => {
334
+ const panel = await mountPanel(undefined, async () => ({ message: 'x' }));
335
+ expect(panel.host.querySelector('h2')?.textContent).toBe('Hello, world!');
199
336
  });
200
337
  });
201
338
  ```
202
339
 
203
- `loadCommands` runs commands as the host does, with the same rules for results.
204
- `loadPanel` mounts a panel with a context like the frame's; its `call` option
205
- answers `ctx.call`, and here it is wired to the real handlers so the panel is
206
- tested against the command it depends on.
340
+ `createTestServer(server, { extensionId })` runs commands as the host does,
341
+ with the same rules for results: `running.commands.run(id, args)` resolves to
342
+ `{ kind: 'notify' | 'openPanel' | 'data' | 'none', … }`. `running.registration`
343
+ lists what the server registered, which is how the test sees that `data` is
344
+ hidden from the palette. `createTestClient(client, { extensionId })` records the
345
+ panels, so the test checks that the id the `open` command points to exists.
346
+
347
+ A panel is tested like any Vue component: `createApp` mounts it in `happy-dom`,
348
+ and `app.provide(PANEL_HANDLE_KEY, handle)` gives it the handle the app would
349
+ provide. The `call` of the handle answers `panel.call`; here it is a stub, and
350
+ in the events recipe it is wired to the real handlers. `vitest.config.ts` adds
351
+ `@vitejs/plugin-vue` so that the test can import the `.vue` file. The app gives
352
+ `<v-btn>` its Vuetify; the test registers a plain button under that name
353
+ (`app.component('v-btn', …)`) and tests the panel, not Vuetify.
207
354
 
208
355
  ## Try and ship
209
356