@oceanum/oceanumlab 4.7.1 → 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 (45) hide show
  1. package/lib/DatameshWidget.js +14 -5
  2. package/lib/StoredNotebooks.d.ts +9 -5
  3. package/lib/StoredNotebooks.js +49 -21
  4. package/lib/auth/serverAuth.js +1 -0
  5. package/lib/auth/startSignIn.d.ts +11 -0
  6. package/lib/auth/startSignIn.js +16 -0
  7. package/lib/auth/tokens.d.ts +7 -0
  8. package/lib/index.d.ts +1 -1
  9. package/lib/index.js +8 -6
  10. package/lib/share/client.d.ts +84 -0
  11. package/lib/share/client.js +205 -0
  12. package/lib/share/events.d.ts +7 -0
  13. package/lib/share/events.js +9 -0
  14. package/lib/share/notebook.d.ts +184 -0
  15. package/lib/share/notebook.js +277 -0
  16. package/lib/share/oceanumOnly.d.ts +49 -0
  17. package/lib/share/oceanumOnly.js +90 -0
  18. package/lib/share/plugin.d.ts +21 -0
  19. package/lib/share/plugin.js +883 -0
  20. package/lib/share/widgets.d.ts +25 -0
  21. package/lib/share/widgets.js +154 -0
  22. package/package.json +5 -1
  23. package/src/DatameshWidget.tsx +21 -2
  24. package/src/StoredNotebooks.tsx +60 -20
  25. package/src/__tests__/OceanumPanel.spec.tsx +60 -4
  26. package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
  27. package/src/__tests__/oceanumOnly.spec.ts +193 -0
  28. package/src/__tests__/shareClient.spec.ts +314 -0
  29. package/src/__tests__/shareNotebook.spec.ts +451 -0
  30. package/src/__tests__/sharePlugin.spec.ts +426 -0
  31. package/src/auth/serverAuth.ts +1 -0
  32. package/src/auth/startSignIn.ts +23 -0
  33. package/src/auth/tokens.ts +7 -0
  34. package/src/index.ts +8 -6
  35. package/src/share/client.ts +288 -0
  36. package/src/share/events.ts +10 -0
  37. package/src/share/notebook.ts +416 -0
  38. package/src/share/oceanumOnly.ts +126 -0
  39. package/src/share/plugin.ts +1096 -0
  40. package/src/share/widgets.ts +217 -0
  41. package/style/index.css +111 -16
  42. package/lib/specStore.d.ts +0 -37
  43. package/lib/specStore.js +0 -57
  44. package/src/__tests__/specStore.spec.ts +0 -126
  45. package/src/specStore.ts +0 -82
@@ -0,0 +1,426 @@
1
+ /**
2
+ * The share plugin's commands, driven through a real CommandRegistry against fakes.
3
+ *
4
+ * These cover what reading cannot: which record or notebook a command decides to act
5
+ * on. Two of them are regressions for defects a review found, both marked below.
6
+ */
7
+ import type { JupyterFrontEnd } from '@jupyterlab/application';
8
+ import type { IDocumentManager } from '@jupyterlab/docmanager';
9
+ import type { INotebookContent } from '@jupyterlab/nbformat';
10
+ import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
11
+ import { CommandRegistry } from '@lumino/commands';
12
+ import type { Widget } from '@lumino/widgets';
13
+ import { Signal } from '@lumino/signaling';
14
+
15
+ import type { IOceanumAuth, IOceanumUser } from '../auth/tokens';
16
+ import type { ISpecRecord } from '../share/notebook';
17
+ import { CommandIDs, sharePlugin } from '../share/plugin';
18
+
19
+ /** What every dialog answers. Accepting with no value is "chose nothing". */
20
+ const mockDialog: { accept: boolean; value: unknown } = {
21
+ accept: true,
22
+ value: null
23
+ };
24
+
25
+ jest.mock('@jupyterlab/apputils', () => {
26
+ const actual = jest.requireActual('@jupyterlab/apputils');
27
+ const answer = async () => ({
28
+ button: { accept: mockDialog.accept },
29
+ value: mockDialog.value
30
+ });
31
+ const quiet = (): void => undefined;
32
+ return {
33
+ ...actual,
34
+ showDialog: answer,
35
+ InputDialog: { ...actual.InputDialog, getText: answer },
36
+ Notification: {
37
+ ...actual.Notification,
38
+ error: quiet,
39
+ success: quiet,
40
+ warning: quiet,
41
+ info: quiet
42
+ }
43
+ };
44
+ });
45
+
46
+ const originalFetch = globalThis.fetch;
47
+ afterEach(() => {
48
+ globalThis.fetch = originalFetch;
49
+ mockDialog.accept = true;
50
+ mockDialog.value = null;
51
+ });
52
+
53
+ const SPECS = 'https://specs.example.com';
54
+ const ID_A = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
55
+ const ID_B = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
56
+
57
+ const USER: IOceanumUser = {
58
+ sub: 'auth0|1',
59
+ email: 'me@example.com',
60
+ name: null,
61
+ activeOrg: null,
62
+ colorScheme: null
63
+ };
64
+
65
+ const notebook = (link?: string): INotebookContent => ({
66
+ nbformat: 4,
67
+ nbformat_minor: 5,
68
+ cells: [],
69
+ metadata: link ? { oceanum: { spec_id: link, specs_url: SPECS } } : {}
70
+ });
71
+
72
+ const record = (id: string, name: string): ISpecRecord => ({
73
+ id,
74
+ name,
75
+ description: null,
76
+ modified: '2026-09-12T10:00:00',
77
+ creator: 'me@example.com',
78
+ spec: notebook()
79
+ });
80
+
81
+ /** Records the spec store calls a command makes, and answers them. */
82
+ function store() {
83
+ const calls: string[] = [];
84
+ const bodies: Record<string, unknown>[] = [];
85
+ const names: Record<string, string> = { [ID_A]: 'Alpha', [ID_B]: 'Beta' };
86
+ const fetcher = async (
87
+ input: RequestInfo | URL,
88
+ init?: RequestInit
89
+ ): Promise<Response> => {
90
+ const url = String(input);
91
+ const method = init?.method ?? 'GET';
92
+ const id = url.split('/').pop() ?? '';
93
+ calls.push(`${method} ${id}`);
94
+ if (init?.body) {
95
+ bodies.push(JSON.parse(String(init.body)));
96
+ }
97
+ return {
98
+ ok: true,
99
+ status: method === 'DELETE' ? 204 : 200,
100
+ json: async () =>
101
+ method === 'GET' && id === 'notebook'
102
+ ? [record(ID_A, 'Alpha'), record(ID_B, 'Beta')]
103
+ : record(id, names[id] ?? 'Alpha')
104
+ } as unknown as Response;
105
+ };
106
+ return { calls, bodies, fetcher };
107
+ }
108
+
109
+ /** A notebook panel with just the surface the plugin touches. */
110
+ function panel(id: string, path: string, link?: string): NotebookPanel {
111
+ const saveState = new Signal<unknown, string>({});
112
+ const metadata: Record<string, unknown> = link
113
+ ? { oceanum: { spec_id: link, specs_url: SPECS } }
114
+ : {};
115
+ return {
116
+ id,
117
+ context: {
118
+ path,
119
+ ready: Promise.resolve(),
120
+ saveState,
121
+ save: async (): Promise<void> => undefined,
122
+ model: {
123
+ toJSON: () => notebook(link),
124
+ getMetadata: (key: string) => metadata[key],
125
+ setMetadata: (key: string, value: unknown) => {
126
+ metadata[key] = value;
127
+ },
128
+ deleteMetadata: (key: string) => {
129
+ delete metadata[key];
130
+ },
131
+ dirty: false
132
+ }
133
+ }
134
+ } as unknown as NotebookPanel;
135
+ }
136
+
137
+ interface IHarness {
138
+ commands: CommandRegistry;
139
+ calls: string[];
140
+ bodies: Record<string, unknown>[];
141
+ saved: string[];
142
+ opened: string[];
143
+ renamed: [string, string][];
144
+ /** What a right-click last landed on; never cleared, as JupyterLab does not. */
145
+ setHit: (node: HTMLElement | null) => void;
146
+ setCurrent: (panel: NotebookPanel | null) => void;
147
+ }
148
+
149
+ function activate(
150
+ options: {
151
+ user?: IOceanumUser | null;
152
+ panels?: NotebookPanel[];
153
+ current?: NotebookPanel | null;
154
+ folder?: Record<string, unknown>;
155
+ } = {}
156
+ ): IHarness {
157
+ const commands = new CommandRegistry();
158
+ const { calls, bodies, fetcher } = store();
159
+ const saved: string[] = [];
160
+ const opened: string[] = [];
161
+ const renamed: [string, string][] = [];
162
+ const panels = options.panels ?? [];
163
+ let current = options.current ?? null;
164
+ let hit: HTMLElement | null = null;
165
+ const folder = options.folder ?? {};
166
+
167
+ const auth = {
168
+ environment: { fileManagement: 'local' },
169
+ urls: { specs: SPECS },
170
+ ready: Promise.resolve(),
171
+ user: options.user === undefined ? USER : options.user,
172
+ userChanged: new Signal<IOceanumAuth, IOceanumUser | null>(
173
+ {} as IOceanumAuth
174
+ ),
175
+ tokenChanged: new Signal<IOceanumAuth, string | null>({} as IOceanumAuth),
176
+ signIn: async (): Promise<void> => undefined,
177
+ signOut: async (): Promise<void> => undefined,
178
+ getAccessToken: async (): Promise<string> => 'tok'
179
+ } as unknown as IOceanumAuth;
180
+
181
+ const app = {
182
+ commands,
183
+ restored: Promise.resolve(),
184
+ shell: {
185
+ get currentWidget() {
186
+ return current;
187
+ },
188
+ activateById: (): void => undefined,
189
+ widgets: (): Widget[] => []
190
+ },
191
+ contextMenu: { addItem: (): void => undefined },
192
+ contextMenuHitTest: (test: (node: HTMLElement) => boolean) => {
193
+ for (let node = hit; node; node = node.parentElement) {
194
+ if (test(node)) {
195
+ return node;
196
+ }
197
+ }
198
+ return undefined;
199
+ }
200
+ } as unknown as JupyterFrontEnd;
201
+
202
+ const contents = {
203
+ get: async (path: string) => {
204
+ if (path === 'Oceanum') {
205
+ return {
206
+ type: 'directory',
207
+ content: Object.keys(folder).map(name => ({ name }))
208
+ };
209
+ }
210
+ const name = path.replace('Oceanum/', '');
211
+ if (!(name in folder)) {
212
+ throw new Error('not found');
213
+ }
214
+ return { type: 'notebook', content: folder[name] };
215
+ },
216
+ save: async (path: string) => {
217
+ saved.push(path);
218
+ return { path };
219
+ },
220
+ newUntitled: async () => ({ path: 'Untitled Folder' }),
221
+ rename: async () => ({ path: 'Oceanum' })
222
+ };
223
+ const docManager = {
224
+ services: { contents },
225
+ openOrReveal: (path: string) => {
226
+ opened.push(path);
227
+ return {};
228
+ },
229
+ rename: async (from: string, to: string): Promise<void> => {
230
+ renamed.push([from, to]);
231
+ },
232
+ deleteFile: async (): Promise<void> => undefined
233
+ } as unknown as IDocumentManager;
234
+
235
+ const tracker = {
236
+ currentWidget: null as NotebookPanel | null,
237
+ forEach: (fn: (p: NotebookPanel) => void) => panels.forEach(fn),
238
+ find: (test: (p: NotebookPanel) => boolean) => panels.find(test) ?? null,
239
+ widgetAdded: new Signal<INotebookTracker, NotebookPanel>(
240
+ {} as INotebookTracker
241
+ ),
242
+ currentChanged: new Signal<INotebookTracker, NotebookPanel | null>(
243
+ {} as INotebookTracker
244
+ )
245
+ } as unknown as INotebookTracker;
246
+ (tracker as { currentWidget: NotebookPanel | null }).currentWidget = current;
247
+
248
+ // The plugin builds its own client; point its fetch at the fake store. A top-level
249
+ // afterEach puts the real one back.
250
+ globalThis.fetch = fetcher as unknown as typeof fetch;
251
+
252
+ const palette = { addItem: (): void => undefined };
253
+ sharePlugin.activate(
254
+ app,
255
+ auth,
256
+ docManager,
257
+ tracker,
258
+ palette,
259
+ null,
260
+ null
261
+ ) as void;
262
+
263
+ return {
264
+ commands,
265
+ calls,
266
+ bodies,
267
+ saved,
268
+ opened,
269
+ renamed,
270
+ setHit: node => {
271
+ hit = node;
272
+ },
273
+ setCurrent: value => {
274
+ current = value;
275
+ (tracker as { currentWidget: NotebookPanel | null }).currentWidget =
276
+ value;
277
+ }
278
+ };
279
+ }
280
+
281
+ describe('renaming a record', () => {
282
+ it('patches just the name, and moves the open notebook to match', async () => {
283
+ const open = panel('panel-a', 'notebooks/Alpha.ipynb', ID_A);
284
+ const harness = activate({ current: open, panels: [open] });
285
+ mockDialog.value = 'Renamed';
286
+
287
+ await harness.commands.execute(CommandIDs.rename, { id: ID_A });
288
+
289
+ // Read once for the dialog's current name, then one patch. No PUT: the notebook
290
+ // is not sent back, so a change another writer made to it cannot be overwritten.
291
+ expect(harness.calls).toEqual([`GET ${ID_A}`, `PATCH ${ID_A}`]);
292
+ expect(harness.bodies).toEqual([{ name: 'Renamed' }]);
293
+ expect(harness.renamed).toEqual([
294
+ ['notebooks/Alpha.ipynb', 'notebooks/Renamed.ipynb']
295
+ ]);
296
+ });
297
+
298
+ it('leaves everything alone when the name is unchanged', async () => {
299
+ const harness = activate();
300
+ mockDialog.value = 'Alpha';
301
+
302
+ await harness.commands.execute(CommandIDs.rename, { id: ID_A });
303
+
304
+ expect(harness.calls).toEqual([`GET ${ID_A}`]);
305
+ });
306
+ });
307
+
308
+ /** A Notebooks tab row, as StoredNotebooks renders it. */
309
+ function row(id: string): HTMLElement {
310
+ const node = document.createElement('button');
311
+ node.className = 'oceanum-notebooks-item';
312
+ node.setAttribute('data-spec-id', id);
313
+ return node;
314
+ }
315
+
316
+ describe('the share commands, invoked from the File menu or the palette', () => {
317
+ // Regression: JupyterLab keeps the last contextmenu event for the life of the page
318
+ // and never clears it, so a hit test answers long after its menu has gone. Reading
319
+ // it unconditionally let the last right-clicked row decide what File > … acted on.
320
+ it('ignore a row that was right-clicked earlier', async () => {
321
+ const current = panel('panel-a', 'notebooks/Alpha.ipynb', ID_A);
322
+ const harness = activate({ current, panels: [current] });
323
+ harness.setHit(row(ID_B));
324
+
325
+ expect(harness.commands.label(CommandIDs.open)).toBe('Open from Oceanum…');
326
+ expect(harness.commands.label(CommandIDs.delete)).toBe(
327
+ 'Delete from Oceanum'
328
+ );
329
+
330
+ await harness.commands.execute(CommandIDs.delete);
331
+
332
+ // The current notebook's record, not the stale row's.
333
+ expect(harness.calls).toContain(`GET ${ID_A}`);
334
+ expect(harness.calls).not.toContain(`GET ${ID_B}`);
335
+ });
336
+
337
+ it('ignore a tab that was right-clicked earlier when saving', async () => {
338
+ const a = panel('panel-a', 'notebooks/Alpha.ipynb', ID_A);
339
+ const b = panel('panel-b', 'notebooks/Beta.ipynb', ID_B);
340
+ const harness = activate({ current: b, panels: [a, b] });
341
+ const tab = document.createElement('li');
342
+ tab.dataset.id = 'panel-a';
343
+ harness.setHit(tab);
344
+
345
+ await harness.commands.execute(CommandIDs.save);
346
+
347
+ expect(harness.calls).toEqual([`PUT ${ID_B}`]);
348
+ });
349
+
350
+ it('open the picker rather than a stale row', async () => {
351
+ const harness = activate();
352
+ harness.setHit(row(ID_B));
353
+
354
+ await harness.commands.execute(CommandIDs.open);
355
+
356
+ // The picker lists; it does not read one record.
357
+ expect(harness.calls).toEqual(['GET notebook']);
358
+ });
359
+ });
360
+
361
+ describe('the share commands, invoked from a Notebooks row', () => {
362
+ it('act on the row under the pointer', async () => {
363
+ const harness = activate();
364
+ harness.setHit(row(ID_B));
365
+
366
+ expect(harness.commands.label(CommandIDs.open, { source: 'row' })).toBe(
367
+ 'Open'
368
+ );
369
+ expect(
370
+ harness.commands.isEnabled(CommandIDs.delete, { source: 'row' })
371
+ ).toBe(true);
372
+
373
+ await harness.commands.execute(CommandIDs.open, { source: 'row' });
374
+
375
+ expect(harness.calls).toContain(`GET ${ID_B}`);
376
+ });
377
+
378
+ it('are disabled while signed out', async () => {
379
+ const harness = activate({ user: null });
380
+ harness.setHit(row(ID_B));
381
+
382
+ for (const command of Object.values(CommandIDs)) {
383
+ expect(harness.commands.isEnabled(command, { source: 'row' })).toBe(
384
+ false
385
+ );
386
+ }
387
+ });
388
+ });
389
+
390
+ describe('opening a record whose local copy already exists', () => {
391
+ // Regression: a second copy is named "<name> (1)", and a record takes its name from
392
+ // its file on every save, so opening the same notebook twice renamed it on Oceanum.
393
+ it('reuses the file an earlier session left, so the record keeps its name', async () => {
394
+ const harness = activate({
395
+ folder: { 'Alpha.ipynb': notebook(ID_A) }
396
+ });
397
+
398
+ await harness.commands.execute(CommandIDs.open, { id: ID_A });
399
+
400
+ expect(harness.saved).toEqual(['Oceanum/Alpha.ipynb']);
401
+ expect(harness.opened).toEqual(['Oceanum/Alpha.ipynb']);
402
+ });
403
+
404
+ it('finds the copy under a numbered name too', async () => {
405
+ const harness = activate({
406
+ folder: {
407
+ 'Alpha.ipynb': notebook(ID_B),
408
+ 'Alpha (1).ipynb': notebook(ID_A)
409
+ }
410
+ });
411
+
412
+ await harness.commands.execute(CommandIDs.open, { id: ID_A });
413
+
414
+ expect(harness.saved).toEqual(['Oceanum/Alpha (1).ipynb']);
415
+ });
416
+
417
+ it('still takes a free name when the file belongs to another record', async () => {
418
+ const harness = activate({
419
+ folder: { 'Alpha.ipynb': notebook(ID_B) }
420
+ });
421
+
422
+ await harness.commands.execute(CommandIDs.open, { id: ID_A });
423
+
424
+ expect(harness.saved).toEqual(['Oceanum/Alpha (1).ipynb']);
425
+ });
426
+ });
@@ -119,6 +119,7 @@ export function readServerAuthOption(
119
119
  oceanumDomain:
120
120
  typeof config.oceanumDomain === 'string' ? config.oceanumDomain : '',
121
121
  signInRedirect: false,
122
+ fileManagement: config.fileManagement === 'oceanum' ? 'oceanum' : 'local',
122
123
  urls
123
124
  };
124
125
  }
@@ -0,0 +1,23 @@
1
+ import type { CommandRegistry } from '@lumino/commands';
2
+
3
+ import { CommandIDs } from './accountWidget';
4
+ import type { IOceanumAuth } from './tokens';
5
+
6
+ /**
7
+ * Start signing in the way this host shows it.
8
+ *
9
+ * On a JupyterLab server the device sign-in command both starts the flow and shows the
10
+ * code dialog; `auth.signIn()` alone starts the flow and shows nothing, so the user is
11
+ * left with a "Signing in…" top bar and no code. notebook.oceanum.io disables that
12
+ * command and provides an auth whose `signIn()` opens the Oceanum sign-in itself.
13
+ */
14
+ export async function startSignIn(
15
+ commands: CommandRegistry,
16
+ auth: IOceanumAuth
17
+ ): Promise<void> {
18
+ if (commands.hasCommand(CommandIDs.signIn)) {
19
+ await commands.execute(CommandIDs.signIn);
20
+ return;
21
+ }
22
+ await auth.signIn();
23
+ }
@@ -25,6 +25,13 @@ export interface IOceanumEnvironment {
25
25
  * the site origin is an allowed callback URL.
26
26
  */
27
27
  readonly signInRedirect: boolean;
28
+ /**
29
+ * Whether Oceanum.io is the only file management this host shows, as on
30
+ * notebook.oceanum.io: the file browser and the File menu's local-file entries go,
31
+ * and every local save is pushed to the spec store. `'local'` (the default when
32
+ * absent) keeps JupyterLab's file management alongside the Oceanum commands.
33
+ */
34
+ readonly fileManagement?: 'local' | 'oceanum';
28
35
  }
29
36
 
30
37
  /** Service base URLs for an environment, without trailing slashes. */
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import { reporterFor } from './progress';
20
20
  import { ConversationPin } from './conversationPin';
21
21
  import { deviceAuthPlugin } from './auth/plugin';
22
22
  import { IOceanumAuth } from './auth/tokens';
23
+ import { CommandIDs as ShareCommandIDs, sharePlugin } from './share/plugin';
23
24
 
24
25
  import { snapshotFromIpynb, snapshotOf } from './notebookContext';
25
26
  import { notebookHost } from './notebookHost';
@@ -149,12 +150,12 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
149
150
  settingsChanged,
150
151
  commands: app.commands,
151
152
  getCurrentWidget,
152
- auth
153
- // No `openStoredNotebook`: nothing can open a spec store record by id yet.
154
- // share-oceanum has `oceanum-share:open`, but it takes no arguments — it opens
155
- // its own picker — so there is nothing to hand an id to. Opening arrives with
156
- // the drive in OCE-182, where the contents API makes it the ordinary path.
157
- // Until then the list reads rather than offering a click that does nothing.
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
+ }
158
159
  });
159
160
  datameshConnectWidget.id = 'datamesh-connect';
160
161
  datameshConnectWidget.title.icon = oceanumIcon;
@@ -399,6 +400,7 @@ export * from './auth/tokens';
399
400
 
400
401
  export default [
401
402
  deviceAuthPlugin,
403
+ sharePlugin,
402
404
  datamesh_connect_extension,
403
405
  oceanum_ai_extension
404
406
  ];