@oceanum/oceanumlab 4.7.2 → 4.9.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.
@@ -9,11 +9,14 @@ import { startSignIn } from '../auth/startSignIn';
9
9
  import { IOceanumAuth } from '../auth/tokens';
10
10
  import { SpecStoreClient, SpecStoreError } from './client';
11
11
  import { notebooksChanged } from './events';
12
- import { buildSpecBody, isSpecId, METADATA_KEY, notebookFromRecord, notebookNamesFor, OCEANUM_DIR, partitionSummaries, QUERY_PARAM, readLink, reportForShareFailures, sanitizeName, shareLink, uniqueNotebookPath } from './notebook';
13
- import { hideFileBrowser, pruneBeforeEachOpen, pruneFileMenu, routeSaves } from './oceanumOnly';
12
+ import { buildSpecBody, isSpecId, METADATA_KEY, notebookFromRecord, notebookFromUpload, notebookNamesFor, NOTEBOOK_DEMO_TYPE, OCEANUM_DIR, partitionSummaries, QUERY_PARAM, readLink, reportForShareFailures, sanitizeName, shareLink, uniqueNotebookPath, unlinkedNotebookFromRecord } from './notebook';
13
+ import { hideFileBrowser, moveToTop, NON_NOTEBOOK_NEW_COMMANDS, OCEANUM_PANEL_ID, pruneBeforeEachOpen, pruneFileMenu, routeSaves } from './oceanumOnly';
14
+ import { chooseNotebookFile } from './upload';
14
15
  import { NotebookListBody, ShareBody } from './widgets';
15
16
  export const CommandIDs = {
16
17
  open: 'oceanum-share:open',
18
+ openExample: 'oceanum-share:open-example',
19
+ upload: 'oceanum-share:upload',
17
20
  save: 'oceanum-share:save',
18
21
  share: 'oceanum-share:share',
19
22
  rename: 'oceanum-share:rename',
@@ -48,6 +51,11 @@ export const sharePlugin = {
48
51
  client: new SpecStoreClient({
49
52
  specsUrl,
50
53
  getAccessToken: () => auth.getAccessToken()
54
+ }),
55
+ demos: new SpecStoreClient({
56
+ specsUrl,
57
+ getAccessToken: () => auth.getAccessToken(),
58
+ specType: NOTEBOOK_DEMO_TYPE
51
59
  })
52
60
  };
53
61
  const oceanumOnly = store !== null && ((_c = auth.environment) === null || _c === void 0 ? void 0 : _c.fileManagement) === 'oceanum';
@@ -221,6 +229,19 @@ export const sharePlugin = {
221
229
  }
222
230
  return null;
223
231
  }
232
+ /** Write a notebook to `path` on the local drive and open it. */
233
+ async function writeAndOpen(path, content) {
234
+ await docManager.services.contents.save(path, {
235
+ type: 'notebook',
236
+ format: 'json',
237
+ content
238
+ });
239
+ const widget = docManager.openOrReveal(path);
240
+ if (!widget) {
241
+ throw new Error(`Could not open ${path}.`);
242
+ }
243
+ return widget;
244
+ }
224
245
  /** Write a record to the local drive as `Oceanum/<name>.ipynb` and open it. */
225
246
  async function openRecord(s, record) {
226
247
  var _a;
@@ -236,14 +257,7 @@ export const sharePlugin = {
236
257
  // named "<name> (1)", and a record takes its name from the file on every save, so
237
258
  // opening the same notebook twice would quietly rename it on Oceanum.
238
259
  const path = (_a = (await pathHoldingRecord(s, record, names))) !== null && _a !== void 0 ? _a : uniqueNotebookPath(record.name, names);
239
- await docManager.services.contents.save(path, {
240
- type: 'notebook',
241
- format: 'json',
242
- content
243
- });
244
- if (!docManager.openOrReveal(path)) {
245
- throw new Error(`Could not open ${path}.`);
246
- }
260
+ await writeAndOpen(path, content);
247
261
  }
248
262
  async function openById(id) {
249
263
  if (!store ||
@@ -257,6 +271,70 @@ export const sharePlugin = {
257
271
  await reportError('Open from Oceanum failed', error);
258
272
  }
259
273
  }
274
+ // The copy each example was last opened into, so opening it again while that copy
275
+ // is still open brings it forward rather than writing another.
276
+ const exampleCopies = new Map();
277
+ // Examples whose copy is still being written, so a second click in that time does
278
+ // not write a second copy.
279
+ const openingExamples = new Set();
280
+ /**
281
+ * Open a copy of an example as a new file in the Oceanum folder, untrusted and
282
+ * linked to nothing. Not `openRecord`: that links the file to the example's record,
283
+ * which is Oceanum's and read-only to everyone else, so the first save would be
284
+ * refused. Unlinked, the first save creates a record of the user's own.
285
+ */
286
+ async function openExample(id, title) {
287
+ if (!store ||
288
+ !(await ensureSignedIn('Opening notebooks needs an Oceanum.io account.'))) {
289
+ return;
290
+ }
291
+ const copy = exampleCopies.get(id);
292
+ if (copy && !copy.isDisposed) {
293
+ app.shell.activateById(copy.id);
294
+ return;
295
+ }
296
+ if (openingExamples.has(id)) {
297
+ return;
298
+ }
299
+ openingExamples.add(id);
300
+ try {
301
+ const record = await store.demos.get(id);
302
+ const content = unlinkedNotebookFromRecord(record);
303
+ const path = uniqueNotebookPath(title || record.name, await oceanumFolderNames());
304
+ exampleCopies.set(id, await writeAndOpen(path, content));
305
+ }
306
+ catch (error) {
307
+ await reportError('Open from Oceanum failed', error);
308
+ }
309
+ finally {
310
+ openingExamples.delete(id);
311
+ }
312
+ }
313
+ /**
314
+ * Bring a notebook from the user's computer into the Oceanum folder and open it,
315
+ * untrusted and linked to nothing. It reaches Oceanum on its first save, like any
316
+ * new notebook; nothing is sent on upload.
317
+ */
318
+ async function uploadNotebook() {
319
+ if (!store ||
320
+ !(await ensureSignedIn('Uploading notebooks needs an Oceanum.io account.'))) {
321
+ return;
322
+ }
323
+ try {
324
+ const file = await chooseNotebookFile();
325
+ if (!file) {
326
+ return;
327
+ }
328
+ const content = notebookFromUpload(await file.text());
329
+ const path = uniqueNotebookPath(file.name, await oceanumFolderNames());
330
+ await writeAndOpen(path, content);
331
+ // Outside Oceanum-only mode a plain Save stays local.
332
+ Notification.info(`Uploaded "${file.name}". ${oceanumOnly ? 'Save it' : 'Use Save to Oceanum'} to store it on Oceanum.io.`, { autoClose: 5000 });
333
+ }
334
+ catch (error) {
335
+ await reportError('Upload failed', error);
336
+ }
337
+ }
260
338
  async function openFromOceanum() {
261
339
  var _a, _b;
262
340
  if (!store ||
@@ -757,6 +835,23 @@ export const sharePlugin = {
757
835
  await (id ? openById(id) : openFromOceanum());
758
836
  }
759
837
  });
838
+ // Run by the Notebooks tab's Examples; not offered in menus or the palette.
839
+ app.commands.addCommand(CommandIDs.openExample, {
840
+ label: 'Open a copy',
841
+ caption: 'Open a copy of an Oceanum example notebook',
842
+ isEnabled: () => signedIn(),
843
+ execute: async (args) => {
844
+ if (isSpecId(args.id)) {
845
+ await openExample(args.id, typeof args.title === 'string' ? args.title : '');
846
+ }
847
+ }
848
+ });
849
+ app.commands.addCommand(CommandIDs.upload, {
850
+ label: label('Upload Notebook…'),
851
+ caption: 'Upload a notebook from this computer, to save on Oceanum.io',
852
+ isEnabled: () => signedIn(),
853
+ execute: () => uploadNotebook()
854
+ });
760
855
  app.commands.addCommand(CommandIDs.save, {
761
856
  label: label('Save to Oceanum'),
762
857
  caption: 'Save the notebook to Oceanum.io',
@@ -808,6 +903,7 @@ export const sharePlugin = {
808
903
  });
809
904
  const commands = [
810
905
  CommandIDs.open,
906
+ CommandIDs.upload,
811
907
  CommandIDs.save,
812
908
  CommandIDs.share,
813
909
  CommandIDs.rename,
@@ -865,7 +961,9 @@ export const sharePlugin = {
865
961
  });
866
962
  if (oceanumOnly) {
867
963
  void app.restored.then(() => {
868
- hideFileBrowser(app.shell);
964
+ hideFileBrowser(app.shell, OCEANUM_PANEL_ID);
965
+ // Oceanum is where notebooks live now, so its panel leads the sidebar.
966
+ moveToTop(app.shell, OCEANUM_PANEL_ID);
869
967
  // IFileMenu is the narrow interface; the menu itself is a Lumino Menu, which
870
968
  // is what can drop items.
871
969
  const fileMenu = mainMenu === null || mainMenu === void 0 ? void 0 : mainMenu.fileMenu;
@@ -873,6 +971,11 @@ export const sharePlugin = {
873
971
  pruneFileMenu(fileMenu);
874
972
  pruneBeforeEachOpen(fileMenu);
875
973
  }
974
+ const newMenu = mainMenu === null || mainMenu === void 0 ? void 0 : mainMenu.fileMenu.newMenu;
975
+ if (newMenu instanceof Menu) {
976
+ pruneFileMenu(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
977
+ pruneBeforeEachOpen(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
978
+ }
876
979
  routeSaves(tracker, pushQuietly);
877
980
  });
878
981
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Ask the user for a notebook file on their computer; null if they cancel.
3
+ *
4
+ * Kept apart from the plugin because it is browser-only: the tests replace it.
5
+ */
6
+ export declare function chooseNotebookFile(): Promise<File | null>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Ask the user for a notebook file on their computer; null if they cancel.
3
+ *
4
+ * Kept apart from the plugin because it is browser-only: the tests replace it.
5
+ */
6
+ export function chooseNotebookFile() {
7
+ return new Promise(resolve => {
8
+ const input = document.createElement('input');
9
+ input.type = 'file';
10
+ input.accept = '.ipynb,application/x-ipynb+json';
11
+ input.hidden = true;
12
+ // Attached while the picker is open: some browsers (Safari) drop the `change`
13
+ // event of a detached input, or collect the input before it fires.
14
+ document.body.appendChild(input);
15
+ const settle = (file) => {
16
+ input.remove();
17
+ resolve(file);
18
+ };
19
+ input.addEventListener('change', () => { var _a, _b; return settle((_b = (_a = input.files) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : null); }, {
20
+ once: true
21
+ });
22
+ // Browsers fire `cancel` when the picker is dismissed; without it the promise
23
+ // would simply never settle, which is harmless.
24
+ input.addEventListener('cancel', () => settle(null), { once: true });
25
+ input.click();
26
+ });
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oceanum/oceanumlab",
3
- "version": "4.7.2",
3
+ "version": "4.9.0",
4
4
  "description": "A Jupyterlab extension to interact with the Oceanum.io platform",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -29,6 +29,12 @@
29
29
  "title": "Iterate",
30
30
  "description": "After running, send each cell's output back to the AI so it can fix an error or take the next step. Only takes effect with Auto-run code on.",
31
31
  "default": false
32
+ },
33
+ "showExamples": {
34
+ "type": "boolean",
35
+ "title": "Show examples",
36
+ "description": "List Oceanum's example notebooks (its Notebook Demo specs) in the Notebooks tab. Also switched from the Examples heading.",
37
+ "default": true
32
38
  }
33
39
  },
34
40
  "patternProperties": {
@@ -555,6 +555,34 @@ function useAiAccess(
555
555
  return access;
556
556
  }
557
557
 
558
+ /**
559
+ * The `showExamples` setting, read again whenever the settings change. True where the
560
+ * host passes no such setting; null while the host's setting has not loaded.
561
+ */
562
+ function useShowExamples(
563
+ settingsChanged: ISignal<unknown, void>,
564
+ read: (() => boolean | null) | undefined
565
+ ): boolean | null {
566
+ const [show, setShow] = React.useState<boolean | null>(() =>
567
+ read ? read() : true
568
+ );
569
+
570
+ React.useEffect(() => {
571
+ if (!read) {
572
+ return;
573
+ }
574
+ const update = () => setShow(read());
575
+ // Anything that changed between the first render and now.
576
+ update();
577
+ settingsChanged.connect(update);
578
+ return () => {
579
+ settingsChanged.disconnect(update);
580
+ };
581
+ }, [settingsChanged, read]);
582
+
583
+ return show;
584
+ }
585
+
558
586
  /**
559
587
  * Shows a message prompting the user to sign in to Oceanum.io, or to configure
560
588
  * their Datamesh token where the site has no sign-in. Only renders when there
@@ -966,6 +994,17 @@ export interface IDatameshWidgetProps {
966
994
  auth?: IOceanumAuth | null;
967
995
  /** Open a stored notebook by spec store id. */
968
996
  openStoredNotebook?: (id: string) => void;
997
+ /** Open a copy of an example by spec store id, named from its title. */
998
+ openExample?: (id: string, title: string) => void;
999
+ /** Upload a notebook from the user's computer. */
1000
+ uploadNotebook?: () => void;
1001
+ /**
1002
+ * The `showExamples` setting: whether the Notebooks tab lists the examples; null
1003
+ * until the settings have loaded.
1004
+ */
1005
+ showExamples?: () => boolean | null;
1006
+ /** Save the `showExamples` setting, from the switch on the Examples heading. */
1007
+ setShowExamples?: (show: boolean) => void;
969
1008
  }
970
1009
 
971
1010
  /**
@@ -983,6 +1022,10 @@ function PanelTabs({
983
1022
  settings,
984
1023
  renderDatamesh,
985
1024
  openStoredNotebook,
1025
+ openExample,
1026
+ uploadNotebook,
1027
+ showExamples,
1028
+ setShowExamples,
986
1029
  selected,
987
1030
  onSelect
988
1031
  }: {
@@ -991,10 +1034,15 @@ function PanelTabs({
991
1034
  settings: IAiSettings;
992
1035
  renderDatamesh: () => React.ReactElement;
993
1036
  openStoredNotebook?: (id: string) => void;
1037
+ openExample?: (id: string, title: string) => void;
1038
+ uploadNotebook?: () => void;
1039
+ showExamples?: () => boolean | null;
1040
+ setShowExamples?: (show: boolean) => void;
994
1041
  selected: string;
995
1042
  onSelect: (id: string) => void;
996
1043
  }): React.ReactElement {
997
1044
  const access = useAiAccess(commands, settings);
1045
+ const examplesShown = useShowExamples(settings.settingsChanged, showExamples);
998
1046
  const tabs: ITab[] = [
999
1047
  {
1000
1048
  id: 'notebooks',
@@ -1006,6 +1054,12 @@ function PanelTabs({
1006
1054
  <StoredNotebooks
1007
1055
  auth={auth}
1008
1056
  onOpen={openStoredNotebook && (item => openStoredNotebook(item.id))}
1057
+ onOpenExample={
1058
+ openExample && (item => openExample(item.id, item.title))
1059
+ }
1060
+ onUpload={uploadNotebook}
1061
+ showExamples={examplesShown}
1062
+ onShowExamplesChange={setShowExamples}
1009
1063
  onSignIn={() => {
1010
1064
  startSignIn(commands, auth).catch(error => {
1011
1065
  console.warn('Oceanum.io sign-in could not start.', error);
@@ -1147,6 +1201,10 @@ export class DatameshConnectWidget extends ReactWidget {
1147
1201
  settings={this.props}
1148
1202
  renderDatamesh={() => this.renderDatamesh()}
1149
1203
  openStoredNotebook={this.props.openStoredNotebook}
1204
+ openExample={this.props.openExample}
1205
+ uploadNotebook={this.props.uploadNotebook}
1206
+ showExamples={this.props.showExamples}
1207
+ setShowExamples={this.props.setShowExamples}
1150
1208
  selected={this.selectedTab}
1151
1209
  onSelect={id => this.selectTab(id)}
1152
1210
  />
@@ -1,11 +1,20 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import { IOceanumAuth } from './auth/tokens';
4
- import { SpecStoreClient } from './share/client';
4
+ import { SpecStoreClient, SpecStoreError } from './share/client';
5
5
  import { notebooksChanged } from './share/events';
6
- import { ISpecSummary, partitionSummaries } from './share/notebook';
6
+ import {
7
+ demoItemsFromSummaries,
8
+ INotebookDemoItem,
9
+ ISpecSummary,
10
+ NOTEBOOK_DEMO_TYPE,
11
+ partitionSummaries
12
+ } from './share/notebook';
7
13
  import { SPEC_ID_ATTRIBUTE } from './share/plugin';
8
14
 
15
+ /** Example rows carry their record id here, not in SPEC_ID_ATTRIBUTE: see Examples. */
16
+ export const EXAMPLE_ID_ATTRIBUTE = 'data-example-id';
17
+
9
18
  export interface IStoredNotebooksProps {
10
19
  auth: IOceanumAuth;
11
20
  /**
@@ -13,6 +22,22 @@ export interface IStoredNotebooksProps {
13
22
  * the list then reads rather than offering a click that does nothing.
14
23
  */
15
24
  onOpen?: (item: ISpecSummary) => void;
25
+ /** Open a copy of an example, as `onOpen` does for a stored notebook. */
26
+ onOpenExample?: (item: INotebookDemoItem) => void;
27
+ /** Upload a notebook from the user's computer; no Upload button without it. */
28
+ onUpload?: () => void;
29
+ /**
30
+ * Whether the examples are listed, or folded under their heading. Default true. Null
31
+ * while the setting is still loading: the Examples wait for it, so examples a user
32
+ * has hidden never show for a moment first.
33
+ */
34
+ showExamples?: boolean | null;
35
+ /**
36
+ * Record the user's choice to show or hide the examples. The Examples heading offers
37
+ * its switch only where this is given: a switch that could not keep its setting
38
+ * would flip back on the next load.
39
+ */
40
+ onShowExamplesChange?: (show: boolean) => void;
16
41
  /**
17
42
  * Start signing in the way this host shows it; without it the auth's own `signIn()`
18
43
  * is used, which on a JupyterLab server shows no code dialog.
@@ -20,10 +45,150 @@ export interface IStoredNotebooksProps {
20
45
  onSignIn?: () => void;
21
46
  }
22
47
 
48
+ /** The Notebook Demo specs the user can read, which are the Examples. */
49
+ type Examples =
50
+ | { state: 'none' }
51
+ | { state: 'error'; message: string }
52
+ | { state: 'ready'; items: INotebookDemoItem[] };
53
+
23
54
  type Load =
24
55
  | { state: 'loading' }
25
56
  | { state: 'error'; message: string }
26
- | { state: 'ready'; items: ISpecSummary[] };
57
+ | { state: 'ready'; items: ISpecSummary[]; examples: Examples };
58
+
59
+ /**
60
+ * List the Notebook Demo specs, each one an example notebook. Only org oceanum may
61
+ * create them (spec store 0.6.2), so every one listed is Oceanum's. Never rejects: a
62
+ * failure here is shown in the Examples section and leaves the rest of the tab alone.
63
+ */
64
+ async function loadExamples(
65
+ auth: IOceanumAuth,
66
+ specsUrl: string
67
+ ): Promise<Examples> {
68
+ try {
69
+ const client = new SpecStoreClient({
70
+ specsUrl,
71
+ getAccessToken: () => auth.getAccessToken(),
72
+ specType: NOTEBOOK_DEMO_TYPE
73
+ });
74
+ return {
75
+ state: 'ready',
76
+ items: demoItemsFromSummaries(await client.list())
77
+ };
78
+ } catch (error) {
79
+ // A store with no Notebook Demo type has no examples, which is not an error.
80
+ if (error instanceof SpecStoreError && error.kind === 'not-found') {
81
+ return { state: 'none' };
82
+ }
83
+ return {
84
+ state: 'error',
85
+ message:
86
+ error instanceof Error ? error.message : 'Could not reach Oceanum.io.'
87
+ };
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The examples: Oceanum's Notebook Demo specs. Their rows deliberately leave out
93
+ * SPEC_ID_ATTRIBUTE and the stored-notebook row class: the share plugin's row menu
94
+ * (Open, Rename, Share, Delete) is bound to those, and none of it applies to
95
+ * Oceanum's own example records.
96
+ */
97
+ function ExampleSections({
98
+ examples,
99
+ onOpen,
100
+ show,
101
+ onShowChange
102
+ }: {
103
+ examples: Examples;
104
+ onOpen?: (item: INotebookDemoItem) => void;
105
+ show: boolean | null;
106
+ onShowChange?: (show: boolean) => void;
107
+ }): React.ReactElement | null {
108
+ // Not yet known, or hidden with no switch to bring them back: nothing to show.
109
+ if (examples.state === 'none' || show === null || (!show && !onShowChange)) {
110
+ return null;
111
+ }
112
+ const count = examples.state === 'ready' ? examples.items.length : null;
113
+ if (count === 0) {
114
+ return null;
115
+ }
116
+ const heading = (
117
+ <div className="oceanum-notebooks-heading">
118
+ Examples
119
+ <span className="oceanum-notebooks-heading-end">
120
+ {count !== null && (
121
+ <span className="oceanum-notebooks-count">{count}</span>
122
+ )}
123
+ {onShowChange && (
124
+ <button
125
+ type="button"
126
+ role="switch"
127
+ aria-checked={show}
128
+ aria-label="Show examples"
129
+ title="Show examples"
130
+ className="oceanum-notebooks-switch"
131
+ onClick={() => onShowChange(!show)}
132
+ >
133
+ <span className="oceanum-notebooks-switch-thumb" />
134
+ </button>
135
+ )}
136
+ </span>
137
+ </div>
138
+ );
139
+ // Hidden: the heading stays, so the switch is there to bring them back.
140
+ if (!show) {
141
+ return <div className="oceanum-notebooks-section">{heading}</div>;
142
+ }
143
+ if (examples.state === 'error') {
144
+ return (
145
+ <div className="oceanum-notebooks-section">
146
+ {heading}
147
+ <div className="oceanum-text-error">
148
+ The examples could not be loaded. {examples.message}
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+ return (
154
+ <div className="oceanum-notebooks-section">
155
+ {heading}
156
+ <ul className="oceanum-notebooks-list">
157
+ {examples.items.map(item => {
158
+ const attributes = {
159
+ [EXAMPLE_ID_ATTRIBUTE]: item.id,
160
+ title: item.summary
161
+ };
162
+ // Names come from the spec store: React escapes them, never set as HTML.
163
+ const name = (
164
+ <span className="oceanum-notebooks-name">{item.title}</span>
165
+ );
166
+ return (
167
+ <li key={item.id}>
168
+ {onOpen ? (
169
+ <button
170
+ type="button"
171
+ className="oceanum-notebooks-example"
172
+ onClick={() => onOpen(item)}
173
+ {...attributes}
174
+ >
175
+ {name}
176
+ </button>
177
+ ) : (
178
+ <div
179
+ className="oceanum-notebooks-example oceanum-notebooks-item-static"
180
+ {...attributes}
181
+ >
182
+ {name}
183
+ </div>
184
+ )}
185
+ </li>
186
+ );
187
+ })}
188
+ </ul>
189
+ </div>
190
+ );
191
+ }
27
192
 
28
193
  function Section({
29
194
  title,
@@ -90,6 +255,10 @@ function Section({
90
255
  export function StoredNotebooks({
91
256
  auth,
92
257
  onOpen,
258
+ onOpenExample,
259
+ onUpload,
260
+ showExamples = true,
261
+ onShowExamplesChange,
93
262
  onSignIn
94
263
  }: IStoredNotebooksProps): React.ReactElement {
95
264
  const [load, setLoad] = React.useState<Load>({ state: 'loading' });
@@ -115,7 +284,7 @@ export function StoredNotebooks({
115
284
  let cancelled = false;
116
285
  const specs = auth.urls?.specs ?? null;
117
286
  if (!user || !specs) {
118
- setLoad({ state: 'ready', items: [] });
287
+ setLoad({ state: 'ready', items: [], examples: { state: 'none' } });
119
288
  return;
120
289
  }
121
290
  setLoad({ state: 'loading' });
@@ -125,9 +294,12 @@ export function StoredNotebooks({
125
294
  specsUrl: specs,
126
295
  getAccessToken: () => auth.getAccessToken()
127
296
  });
128
- const items = await client.list();
297
+ const [items, examples] = await Promise.all([
298
+ client.list(),
299
+ loadExamples(auth, specs)
300
+ ]);
129
301
  if (!cancelled) {
130
- setLoad({ state: 'ready', items });
302
+ setLoad({ state: 'ready', items, examples });
131
303
  }
132
304
  } catch (error) {
133
305
  if (!cancelled) {
@@ -189,6 +361,19 @@ export function StoredNotebooks({
189
361
  const { mine, shared } = partitionSummaries(load.items, user.email);
190
362
  return (
191
363
  <div className="oceanum-notebooks">
364
+ {/* Without a spec store the upload command does nothing. */}
365
+ {onUpload && auth.urls?.specs ? (
366
+ <div className="oceanum-notebooks-actions">
367
+ <button
368
+ type="button"
369
+ className="oceanum-notebooks-upload"
370
+ title="Upload a notebook from this computer, to save on Oceanum.io"
371
+ onClick={onUpload}
372
+ >
373
+ Upload notebook…
374
+ </button>
375
+ </div>
376
+ ) : null}
192
377
  <Section
193
378
  title="My notebooks"
194
379
  items={mine}
@@ -201,6 +386,12 @@ export function StoredNotebooks({
201
386
  empty="No notebooks have been shared with you."
202
387
  onOpen={onOpen}
203
388
  />
389
+ <ExampleSections
390
+ examples={load.examples}
391
+ onOpen={onOpenExample}
392
+ show={showExamples}
393
+ onShowChange={onShowExamplesChange}
394
+ />
204
395
  </div>
205
396
  );
206
397
  }