@oceanum/oceanumlab 4.7.0 → 4.7.2

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.
Files changed (81) hide show
  1. package/README.md +102 -11
  2. package/lib/DatameshUI.d.ts +1 -6
  3. package/lib/DatameshUI.js +1 -9
  4. package/lib/DatameshWidget.d.ts +18 -2
  5. package/lib/DatameshWidget.js +80 -12
  6. package/lib/StoredNotebooks.d.ts +20 -0
  7. package/lib/StoredNotebooks.js +112 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +1 -1
  11. package/lib/aiBackend.js +1 -1
  12. package/lib/auth/accountWidget.d.ts +35 -0
  13. package/lib/auth/accountWidget.js +95 -0
  14. package/lib/auth/claims.d.ts +3 -0
  15. package/lib/auth/claims.js +25 -0
  16. package/lib/auth/plugin.d.ts +9 -0
  17. package/lib/auth/plugin.js +126 -0
  18. package/lib/auth/serverAuth.d.ts +79 -0
  19. package/lib/auth/serverAuth.js +254 -0
  20. package/lib/auth/startSignIn.d.ts +11 -0
  21. package/lib/auth/startSignIn.js +16 -0
  22. package/lib/auth/tokens.d.ts +102 -0
  23. package/lib/auth/tokens.js +9 -0
  24. package/lib/chatRouter.d.ts +13 -1
  25. package/lib/chatRouter.js +9 -2
  26. package/lib/constants.d.ts +1 -1
  27. package/lib/constants.js +1 -1
  28. package/lib/datameshUiUrl.d.ts +2 -2
  29. package/lib/datameshUiUrl.js +6 -6
  30. package/lib/index.d.ts +8 -1
  31. package/lib/index.js +58 -17
  32. package/lib/share/client.d.ts +84 -0
  33. package/lib/share/client.js +205 -0
  34. package/lib/share/events.d.ts +7 -0
  35. package/lib/share/events.js +9 -0
  36. package/lib/share/notebook.d.ts +184 -0
  37. package/lib/share/notebook.js +277 -0
  38. package/lib/share/oceanumOnly.d.ts +49 -0
  39. package/lib/share/oceanumOnly.js +90 -0
  40. package/lib/share/plugin.d.ts +21 -0
  41. package/lib/share/plugin.js +883 -0
  42. package/lib/share/widgets.d.ts +25 -0
  43. package/lib/share/widgets.js +154 -0
  44. package/package.json +13 -1
  45. package/schema/datamesh-connect.json +3 -12
  46. package/src/DatameshUI.tsx +1 -10
  47. package/src/DatameshWidget.tsx +143 -19
  48. package/src/StoredNotebooks.tsx +206 -0
  49. package/src/Tabs.tsx +102 -0
  50. package/src/__tests__/DatameshWidget.spec.tsx +33 -20
  51. package/src/__tests__/OceanumPanel.spec.tsx +157 -0
  52. package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
  53. package/src/__tests__/Tabs.spec.tsx +168 -0
  54. package/src/__tests__/aiBackend.spec.ts +5 -6
  55. package/src/__tests__/chatRouter.spec.ts +9 -15
  56. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  57. package/src/__tests__/oceanumOnly.spec.ts +193 -0
  58. package/src/__tests__/pluginArity.spec.ts +24 -0
  59. package/src/__tests__/serverAuth.spec.ts +277 -0
  60. package/src/__tests__/settingsSchema.spec.ts +73 -0
  61. package/src/__tests__/shareClient.spec.ts +314 -0
  62. package/src/__tests__/shareNotebook.spec.ts +451 -0
  63. package/src/__tests__/sharePlugin.spec.ts +426 -0
  64. package/src/aiBackend.ts +1 -1
  65. package/src/auth/accountWidget.ts +113 -0
  66. package/src/auth/claims.ts +30 -0
  67. package/src/auth/plugin.ts +155 -0
  68. package/src/auth/serverAuth.ts +342 -0
  69. package/src/auth/startSignIn.ts +23 -0
  70. package/src/auth/tokens.ts +111 -0
  71. package/src/chatRouter.ts +8 -4
  72. package/src/constants.ts +1 -1
  73. package/src/datameshUiUrl.ts +6 -6
  74. package/src/index.ts +87 -36
  75. package/src/share/client.ts +288 -0
  76. package/src/share/events.ts +10 -0
  77. package/src/share/notebook.ts +416 -0
  78. package/src/share/oceanumOnly.ts +126 -0
  79. package/src/share/plugin.ts +1096 -0
  80. package/src/share/widgets.ts +217 -0
  81. package/style/index.css +266 -13
package/src/index.ts CHANGED
@@ -18,6 +18,10 @@ import { requestAPI } from './handler';
18
18
  import { ChatRouter, ChatRouterError, ChatMessage } from './chatRouter';
19
19
  import { reporterFor } from './progress';
20
20
  import { ConversationPin } from './conversationPin';
21
+ import { deviceAuthPlugin } from './auth/plugin';
22
+ import { IOceanumAuth } from './auth/tokens';
23
+ import { CommandIDs as ShareCommandIDs, sharePlugin } from './share/plugin';
24
+
21
25
  import { snapshotFromIpynb, snapshotOf } from './notebookContext';
22
26
  import { notebookHost } from './notebookHost';
23
27
  import { KernelHandoff } from './kernelHandoff';
@@ -53,20 +57,27 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
53
57
  ISettingRegistry,
54
58
  IStateDB
55
59
  ],
60
+ // Optional so the panel still loads on a host with no Oceanum environment; the
61
+ // Notebooks tab then explains why it is empty instead of the panel failing to start.
62
+ optional: [IOceanumAuth],
56
63
  activate: (
57
64
  app: JupyterFrontEnd,
58
65
  palette: ICommandPalette,
59
66
  restorer: ILayoutRestorer,
60
67
  status: ILabStatus,
61
- settingRegistry: ISettingRegistry
68
+ settingRegistry: ISettingRegistry,
69
+ stateDB: IStateDB,
70
+ auth: IOceanumAuth | null
62
71
  ) => {
63
72
  console.log('Oceanum datamesh connect extension is loaded');
64
73
 
65
- // The `datameshUiUrl` setting; the default until the settings load.
66
- let datameshUiUrl = DATAMESH_UI_SERVICE;
67
- // The `aiBackendUrl` and `datameshToken` settings for the AI chat; the
68
- // defaults until the settings load.
69
- let aiBackendUrl = OCEANUM_AI_BACKEND_URL;
74
+ // Where the Datamesh UI and Oceanum AI live is the deployment's business, so it comes
75
+ // from the sign-in environment (the Jupyter server's configuration, or the notebook
76
+ // site's) and falls back to production. These were user settings until 4.7.1; a
77
+ // user-editable service address is somewhere to send the user's credential.
78
+ const datameshUiUrl = auth?.urls?.datameshUi ?? DATAMESH_UI_SERVICE;
79
+ const aiBackendUrl = auth?.urls?.ai ?? OCEANUM_AI_BACKEND_URL;
80
+ // The `datameshToken` setting for the AI chat; empty until the settings load.
70
81
  let datameshTokenSetting = '';
71
82
  // Tells the sidebar the settings above have changed.
72
83
  const settingsChanged = new Signal<JupyterFrontEnd, void>(app);
@@ -88,14 +99,7 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
88
99
  }).then(res => console.log(res));
89
100
  }
90
101
  window.injectToken = set.get('injectToken').user as boolean;
91
- aiBackendUrl = set.get('aiBackendUrl').composite as string;
92
102
  datameshTokenSetting = set.get('datameshToken').composite as string;
93
- // An open Datamesh UI panel follows a change of address.
94
- datameshUiUrl = set.get('datameshUiUrl').composite as string;
95
- const datameshUI = findDatameshUI();
96
- if (datameshUI) {
97
- datameshUI.url = datameshUiUrl;
98
- }
99
103
  settingsChanged.emit();
100
104
  };
101
105
  //Try to get the datamesh token from the envars
@@ -145,7 +149,13 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
145
149
  aiBackendUrl: () => aiBackendUrl,
146
150
  settingsChanged,
147
151
  commands: app.commands,
148
- getCurrentWidget
152
+ getCurrentWidget,
153
+ auth,
154
+ openStoredNotebook: id => {
155
+ app.commands.execute(ShareCommandIDs.open, { id }).catch(error => {
156
+ console.error('Oceanum: could not open the stored notebook.', error);
157
+ });
158
+ }
149
159
  });
150
160
  datameshConnectWidget.id = 'datamesh-connect';
151
161
  datameshConnectWidget.title.icon = oceanumIcon;
@@ -153,8 +163,30 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
153
163
 
154
164
  restorer.add(datameshConnectWidget, 'datamesh-connect');
155
165
 
166
+ // The layout restorer restores the widget's place in the shell, not fields on it,
167
+ // so the chosen tab is kept here. Restoring is best effort: a missing or unreadable
168
+ // entry just leaves the default.
169
+ const TAB_STATE_KEY = `${PLUGIN_ID}:tab`;
170
+ void stateDB
171
+ .fetch(TAB_STATE_KEY)
172
+ .then(value => {
173
+ if (typeof value === 'string') {
174
+ datameshConnectWidget.selectTab(value);
175
+ }
176
+ })
177
+ .catch((): void => undefined);
178
+ datameshConnectWidget.tabChanged.connect((_, id) => {
179
+ void stateDB.save(TAB_STATE_KEY, id);
180
+ });
181
+
156
182
  // Rank has been chosen somewhat arbitrarily to give priority to the running
157
183
  // sessions widget in the sidebar.
184
+ //
185
+ // Oceanum Notebook wants this panel first instead, but that is a property of that
186
+ // distribution rather than of the extension: in a plain JupyterLab the file browser
187
+ // is the primary navigation surface and displacing it would be a regression. A
188
+ // distribution moves it with JupyterLab's own shell user-layout settings, keyed on
189
+ // this widget's id, rather than this extension hard-coding one host's preference.
158
190
  app.shell.add(datameshConnectWidget, 'left', { rank: 900 });
159
191
 
160
192
  app.commands.addCommand('datamesh-ui:open', {
@@ -174,10 +206,12 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
174
206
  id: '@oceanum/oceanumlab:ai-chat',
175
207
  autoStart: true,
176
208
  requires: [INotebookTracker, ISettingRegistry],
209
+ optional: [IOceanumAuth],
177
210
  activate: (
178
211
  app: JupyterFrontEnd,
179
212
  notebookTracker: INotebookTracker,
180
- settingRegistry: ISettingRegistry
213
+ settingRegistry: ISettingRegistry,
214
+ auth: IOceanumAuth | null
181
215
  ) => {
182
216
  console.log('Oceanum AI chat extension is loaded');
183
217
 
@@ -197,26 +231,31 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
197
231
  // The panel the current request read its selected cell from, if the
198
232
  // notebook was open: a code answer may replace that cell only there.
199
233
  let readFrom: NotebookPanel | null = null;
200
- const router = new ChatRouter(settings, app.commands, async () => {
201
- const open = await pin.current();
202
- readFrom = open;
203
- if (open) {
204
- return snapshotOf(open.content);
205
- }
206
- const path = pin.path();
207
- if (!path) {
208
- return null;
209
- }
210
- try {
211
- const file = await app.serviceManager.contents.get(path, {
212
- content: true
213
- });
214
- return snapshotFromIpynb(file.content);
215
- } catch {
216
- // Gone: an answer's cells will go into a new notebook instead.
217
- return null;
218
- }
219
- });
234
+ const router = new ChatRouter(
235
+ settings,
236
+ app.commands,
237
+ async () => {
238
+ const open = await pin.current();
239
+ readFrom = open;
240
+ if (open) {
241
+ return snapshotOf(open.content);
242
+ }
243
+ const path = pin.path();
244
+ if (!path) {
245
+ return null;
246
+ }
247
+ try {
248
+ const file = await app.serviceManager.contents.get(path, {
249
+ content: true
250
+ });
251
+ return snapshotFromIpynb(file.content);
252
+ } catch {
253
+ // Gone: an answer's cells will go into a new notebook instead.
254
+ return null;
255
+ }
256
+ },
257
+ () => auth?.urls?.ai ?? ''
258
+ );
220
259
  // Asked only when an answer has blocks to place: brings the
221
260
  // conversation's notebook to the front, opening it again if closed.
222
261
  const handoff = new KernelHandoff(
@@ -352,4 +391,16 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
352
391
  }
353
392
  };
354
393
 
355
- export default [datamesh_connect_extension, oceanum_ai_extension];
394
+ /**
395
+ * The sign-in contract, exported so another extension can provide or consume it: a Lumino
396
+ * token is matched by object identity, so everyone has to import this one object, and
397
+ * JupyterLab shares `@oceanum/oceanumlab` between extensions as a singleton module.
398
+ */
399
+ export * from './auth/tokens';
400
+
401
+ export default [
402
+ deviceAuthPlugin,
403
+ sharePlugin,
404
+ datamesh_connect_extension,
405
+ oceanum_ai_extension
406
+ ];
@@ -0,0 +1,288 @@
1
+ import {
2
+ isSpecId,
3
+ ISpecBody,
4
+ ISpecPatch,
5
+ ISpecRecord,
6
+ ISpecSummary
7
+ } from './notebook';
8
+
9
+ export type ErrorKind =
10
+ | 'signed-out'
11
+ | 'expired'
12
+ | 'forbidden'
13
+ | 'not-found'
14
+ | 'too-large'
15
+ | 'network'
16
+ | 'invalid'
17
+ | 'unsupported'
18
+ | 'server';
19
+
20
+ const MESSAGES: Record<Exclude<ErrorKind, 'server'>, string> = {
21
+ 'signed-out': 'Sign in to Oceanum.io to continue.',
22
+ expired: 'Your Oceanum session has expired, sign in again.',
23
+ forbidden:
24
+ 'You have no access to this notebook. It may not exist, or was created under another organisation.',
25
+ 'not-found': 'This notebook no longer exists on Oceanum.io.',
26
+ 'too-large': 'The notebook is too large for Oceanum.io (32 MiB limit).',
27
+ network:
28
+ 'Could not reach Oceanum.io. Check your connection; very large notebooks can also fail this way.',
29
+ invalid: 'Oceanum.io returned an unexpected response.',
30
+ unsupported: 'This Oceanum.io service does not offer that operation yet.'
31
+ };
32
+
33
+ /** A spec store failure with a message that is safe to show to the user. */
34
+ export class SpecStoreError extends Error {
35
+ constructor(
36
+ readonly kind: ErrorKind,
37
+ readonly status: number | null = null
38
+ ) {
39
+ super(
40
+ kind === 'server'
41
+ ? `Oceanum.io returned an error (HTTP ${status}).`
42
+ : MESSAGES[kind]
43
+ );
44
+ this.name = 'SpecStoreError';
45
+ }
46
+ }
47
+
48
+ /** Map a failed HTTP status to an error. */
49
+ export function errorForStatus(status: number): SpecStoreError {
50
+ switch (status) {
51
+ // The spec store's authentication middleware answers a bad or expired token with 400.
52
+ case 400:
53
+ return new SpecStoreError('expired', status);
54
+ // Casbin answers 403 both for no access and for a record that does not exist.
55
+ case 403:
56
+ return new SpecStoreError('forbidden', status);
57
+ case 404:
58
+ return new SpecStoreError('not-found', status);
59
+ case 405:
60
+ // The route is there but not this method: a store from before it had PATCH.
61
+ return new SpecStoreError('unsupported', status);
62
+ case 413:
63
+ return new SpecStoreError('too-large', status);
64
+ default:
65
+ return new SpecStoreError('server', status);
66
+ }
67
+ }
68
+
69
+ export type SharePermission = 'read' | 'write';
70
+
71
+ /** A permission grant, as accepted by `POST /specs/notebook/{id}/permissions`. */
72
+ export type PermissionGrant =
73
+ | { type: 'user'; entity: string; permission: SharePermission }
74
+ | { type: 'public'; entity: ''; permission: 'read' };
75
+
76
+ /**
77
+ * What the share dialog asks for: add grants, or remove one (e.g. stop public access).
78
+ *
79
+ * Granting carries a list because the dialog shares with several people at once, while
80
+ * the spec store takes one grant per request. `invalid` carries back anything typed that
81
+ * is not a shareable address, so the caller can name it rather than failing with
82
+ * "invalid email".
83
+ */
84
+ export type ShareChoice =
85
+ | { action: 'grant'; grants: PermissionGrant[]; invalid: string[] }
86
+ | { action: 'revoke'; grant: PermissionGrant };
87
+
88
+ export interface ISpecStoreClientOptions {
89
+ /** Spec store base URL, e.g. `https://specs.oceanum.io`. */
90
+ specsUrl: string;
91
+ /** Returns a current access token or `null`. Called for every request. */
92
+ getAccessToken: () => Promise<string | null>;
93
+ fetch?: typeof fetch;
94
+ }
95
+
96
+ function isSummary(value: unknown): value is ISpecSummary {
97
+ if (typeof value !== 'object' || value === null) {
98
+ return false;
99
+ }
100
+ const v = value as Record<string, unknown>;
101
+ return (
102
+ isSpecId(v.id) &&
103
+ typeof v.name === 'string' &&
104
+ typeof v.modified === 'string'
105
+ );
106
+ }
107
+
108
+ function isRecord(value: unknown): value is ISpecRecord {
109
+ return isSummary(value) && 'spec' in value;
110
+ }
111
+
112
+ /** Client for the `notebook` spec type of the Oceanum spec store. */
113
+ export class SpecStoreClient {
114
+ constructor(options: ISpecStoreClientOptions) {
115
+ this._base = `${options.specsUrl.replace(/\/+$/, '')}/specs/notebook`;
116
+ this._getAccessToken = options.getAccessToken;
117
+ this._fetch = options.fetch ?? ((input, init) => fetch(input, init));
118
+ }
119
+
120
+ /** Every notebook the user may read, including public ones. */
121
+ async list(): Promise<ISpecSummary[]> {
122
+ const data = await this._request('GET', this._base, { auth: 'required' });
123
+ if (!Array.isArray(data) || !data.every(isSummary)) {
124
+ throw new SpecStoreError('invalid');
125
+ }
126
+ return data;
127
+ }
128
+
129
+ /** Read a record, anonymously when signed out. */
130
+ async get(id: string): Promise<ISpecRecord> {
131
+ return this._record(
132
+ await this._request('GET', this._url(id), { auth: 'optional' })
133
+ );
134
+ }
135
+
136
+ /** Create a record. The server mints the id: use the returned record's. */
137
+ async create(body: ISpecBody): Promise<ISpecRecord> {
138
+ return this._record(
139
+ await this._request('POST', this._base, {
140
+ auth: 'required',
141
+ body: pick(body)
142
+ })
143
+ );
144
+ }
145
+
146
+ /** Replace a record's name, description and notebook. */
147
+ async update(id: string, body: ISpecBody): Promise<ISpecRecord> {
148
+ return this._record(
149
+ await this._request('PUT', this._url(id), {
150
+ auth: 'required',
151
+ body: pick(body)
152
+ })
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Change some of a record's fields and leave the rest as they are. Needs the same
158
+ * write access as `update`, which the store enforces a patch as.
159
+ */
160
+ async patch(id: string, changes: ISpecPatch): Promise<ISpecRecord> {
161
+ if (Object.keys(changes).length === 0) {
162
+ // The store answers an empty patch with 400; there is nothing to send anyway.
163
+ throw new SpecStoreError('invalid');
164
+ }
165
+ return this._record(
166
+ await this._request('PATCH', this._url(id), {
167
+ auth: 'required',
168
+ body: changes
169
+ })
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Give a record a new name, without sending the notebook back with it.
175
+ *
176
+ * Falls back to reading the record and putting it back where the store has no PATCH
177
+ * (production had none when this was written). That fallback races another writer's
178
+ * change to the notebook, which is the whole reason to prefer a patch.
179
+ */
180
+ async rename(id: string, name: string): Promise<ISpecRecord> {
181
+ try {
182
+ return await this.patch(id, { name });
183
+ } catch (error) {
184
+ if (!(error instanceof SpecStoreError) || error.kind !== 'unsupported') {
185
+ throw error;
186
+ }
187
+ const record = await this.get(id);
188
+ return this.update(id, {
189
+ name,
190
+ description: record.description,
191
+ spec: record.spec as ISpecBody['spec']
192
+ });
193
+ }
194
+ }
195
+
196
+ /** Delete a record (requires admin access to it). */
197
+ async remove(id: string): Promise<void> {
198
+ await this._request('DELETE', this._url(id), { auth: 'required' });
199
+ }
200
+
201
+ /** Grant a permission on a record (requires admin access to it). */
202
+ async addPermission(id: string, grant: PermissionGrant): Promise<void> {
203
+ await this._request('POST', `${this._url(id)}/permissions`, {
204
+ auth: 'required',
205
+ body: {
206
+ type: grant.type,
207
+ entity: grant.entity,
208
+ permission: grant.permission
209
+ }
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Remove a permission (requires admin access). The server matches the exact grant and
215
+ * answers 404 when there is no such permission.
216
+ */
217
+ async removePermission(id: string, grant: PermissionGrant): Promise<void> {
218
+ await this._request('DELETE', `${this._url(id)}/permissions`, {
219
+ auth: 'required',
220
+ body: {
221
+ type: grant.type,
222
+ entity: grant.entity,
223
+ permission: grant.permission
224
+ }
225
+ });
226
+ }
227
+
228
+ private _url(id: string): string {
229
+ if (!isSpecId(id)) {
230
+ throw new SpecStoreError('invalid');
231
+ }
232
+ return `${this._base}/${id}`;
233
+ }
234
+
235
+ private _record(data: unknown): ISpecRecord {
236
+ if (!isRecord(data)) {
237
+ throw new SpecStoreError('invalid');
238
+ }
239
+ return data;
240
+ }
241
+
242
+ private async _request(
243
+ method: string,
244
+ url: string,
245
+ options: { auth: 'required' | 'optional'; body?: object }
246
+ ): Promise<unknown> {
247
+ const token = await this._getAccessToken();
248
+ if (token === null && options.auth === 'required') {
249
+ throw new SpecStoreError('signed-out');
250
+ }
251
+ const headers: Record<string, string> = {};
252
+ if (token !== null) {
253
+ headers['Authorization'] = `Bearer ${token}`;
254
+ }
255
+ const init: RequestInit = { method, headers };
256
+ if (options.body !== undefined) {
257
+ headers['Content-Type'] = 'application/json';
258
+ init.body = JSON.stringify(options.body);
259
+ }
260
+
261
+ let response: Response;
262
+ try {
263
+ response = await this._fetch(url, init);
264
+ } catch {
265
+ throw new SpecStoreError('network');
266
+ }
267
+ if (!response.ok) {
268
+ throw errorForStatus(response.status);
269
+ }
270
+ if (response.status === 204) {
271
+ return null;
272
+ }
273
+ try {
274
+ return await response.json();
275
+ } catch {
276
+ throw new SpecStoreError('invalid');
277
+ }
278
+ }
279
+
280
+ private readonly _base: string;
281
+ private readonly _getAccessToken: () => Promise<string | null>;
282
+ private readonly _fetch: typeof fetch;
283
+ }
284
+
285
+ /** Only the fields the server accepts; in particular never send an `id`. */
286
+ function pick(body: ISpecBody): ISpecBody {
287
+ return { name: body.name, description: body.description, spec: body.spec };
288
+ }
@@ -0,0 +1,10 @@
1
+ import { Signal } from '@lumino/signaling';
2
+
3
+ /**
4
+ * Emits after this extension creates, renames or deletes a notebook record, so the
5
+ * Notebooks tab can list again. A plain module-level signal: the tab and the commands
6
+ * live in different plugins, and the change is the only thing they need to share.
7
+ */
8
+ // The sender is a throwaway object, not null: Lumino keys its receivers by sender in a
9
+ // WeakMap, which refuses null.
10
+ export const notebooksChanged = new Signal<object, void>({});