@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.
- package/lib/DatameshWidget.js +14 -5
- package/lib/StoredNotebooks.d.ts +9 -5
- package/lib/StoredNotebooks.js +49 -21
- package/lib/auth/serverAuth.js +1 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +7 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +8 -6
- package/lib/share/client.d.ts +84 -0
- package/lib/share/client.js +205 -0
- package/lib/share/events.d.ts +7 -0
- package/lib/share/events.js +9 -0
- package/lib/share/notebook.d.ts +184 -0
- package/lib/share/notebook.js +277 -0
- package/lib/share/oceanumOnly.d.ts +49 -0
- package/lib/share/oceanumOnly.js +90 -0
- package/lib/share/plugin.d.ts +21 -0
- package/lib/share/plugin.js +883 -0
- package/lib/share/widgets.d.ts +25 -0
- package/lib/share/widgets.js +154 -0
- package/package.json +5 -1
- package/src/DatameshWidget.tsx +21 -2
- package/src/StoredNotebooks.tsx +60 -20
- package/src/__tests__/OceanumPanel.spec.tsx +60 -4
- package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
- package/src/__tests__/oceanumOnly.spec.ts +193 -0
- package/src/__tests__/shareClient.spec.ts +314 -0
- package/src/__tests__/shareNotebook.spec.ts +451 -0
- package/src/__tests__/sharePlugin.spec.ts +426 -0
- package/src/auth/serverAuth.ts +1 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +7 -0
- package/src/index.ts +8 -6
- package/src/share/client.ts +288 -0
- package/src/share/events.ts +10 -0
- package/src/share/notebook.ts +416 -0
- package/src/share/oceanumOnly.ts +126 -0
- package/src/share/plugin.ts +1096 -0
- package/src/share/widgets.ts +217 -0
- package/style/index.css +111 -16
- package/lib/specStore.d.ts +0 -37
- package/lib/specStore.js +0 -57
- package/src/__tests__/specStore.spec.ts +0 -126
- 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
|
+
});
|
package/src/auth/serverAuth.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/auth/tokens.ts
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
];
|