@oceanum/oceanumlab 4.7.2 → 4.8.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,6 +1,8 @@
1
1
  import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
2
  export declare const CommandIDs: {
3
3
  readonly open: "oceanum-share:open";
4
+ readonly openExample: "oceanum-share:open-example";
5
+ readonly upload: "oceanum-share:upload";
4
6
  readonly save: "oceanum-share:save";
5
7
  readonly share: "oceanum-share:share";
6
8
  readonly rename: "oceanum-share:rename";
@@ -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, 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',
@@ -221,6 +224,19 @@ export const sharePlugin = {
221
224
  }
222
225
  return null;
223
226
  }
227
+ /** Write a notebook to `path` on the local drive and open it. */
228
+ async function writeAndOpen(path, content) {
229
+ await docManager.services.contents.save(path, {
230
+ type: 'notebook',
231
+ format: 'json',
232
+ content
233
+ });
234
+ const widget = docManager.openOrReveal(path);
235
+ if (!widget) {
236
+ throw new Error(`Could not open ${path}.`);
237
+ }
238
+ return widget;
239
+ }
224
240
  /** Write a record to the local drive as `Oceanum/<name>.ipynb` and open it. */
225
241
  async function openRecord(s, record) {
226
242
  var _a;
@@ -236,14 +252,7 @@ export const sharePlugin = {
236
252
  // named "<name> (1)", and a record takes its name from the file on every save, so
237
253
  // opening the same notebook twice would quietly rename it on Oceanum.
238
254
  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
- }
255
+ await writeAndOpen(path, content);
247
256
  }
248
257
  async function openById(id) {
249
258
  if (!store ||
@@ -257,6 +266,70 @@ export const sharePlugin = {
257
266
  await reportError('Open from Oceanum failed', error);
258
267
  }
259
268
  }
269
+ // The copy each example was last opened into, so opening it again while that copy
270
+ // is still open brings it forward rather than writing another.
271
+ const exampleCopies = new Map();
272
+ // Examples whose copy is still being written, so a second click in that time does
273
+ // not write a second copy.
274
+ const openingExamples = new Set();
275
+ /**
276
+ * Open a copy of an example as a new file in the Oceanum folder, untrusted and
277
+ * linked to nothing. Not `openRecord`: that links the file to the example's record,
278
+ * which is Oceanum's and read-only to everyone else, so the first save would be
279
+ * refused. Unlinked, the first save creates a record of the user's own.
280
+ */
281
+ async function openExample(id, title) {
282
+ if (!store ||
283
+ !(await ensureSignedIn('Opening notebooks needs an Oceanum.io account.'))) {
284
+ return;
285
+ }
286
+ const copy = exampleCopies.get(id);
287
+ if (copy && !copy.isDisposed) {
288
+ app.shell.activateById(copy.id);
289
+ return;
290
+ }
291
+ if (openingExamples.has(id)) {
292
+ return;
293
+ }
294
+ openingExamples.add(id);
295
+ try {
296
+ const record = await store.client.get(id);
297
+ const content = unlinkedNotebookFromRecord(record);
298
+ const path = uniqueNotebookPath(title || record.name, await oceanumFolderNames());
299
+ exampleCopies.set(id, await writeAndOpen(path, content));
300
+ }
301
+ catch (error) {
302
+ await reportError('Open from Oceanum failed', error);
303
+ }
304
+ finally {
305
+ openingExamples.delete(id);
306
+ }
307
+ }
308
+ /**
309
+ * Bring a notebook from the user's computer into the Oceanum folder and open it,
310
+ * untrusted and linked to nothing. It reaches Oceanum on its first save, like any
311
+ * new notebook; nothing is sent on upload.
312
+ */
313
+ async function uploadNotebook() {
314
+ if (!store ||
315
+ !(await ensureSignedIn('Uploading notebooks needs an Oceanum.io account.'))) {
316
+ return;
317
+ }
318
+ try {
319
+ const file = await chooseNotebookFile();
320
+ if (!file) {
321
+ return;
322
+ }
323
+ const content = notebookFromUpload(await file.text());
324
+ const path = uniqueNotebookPath(file.name, await oceanumFolderNames());
325
+ await writeAndOpen(path, content);
326
+ // Outside Oceanum-only mode a plain Save stays local.
327
+ Notification.info(`Uploaded "${file.name}". ${oceanumOnly ? 'Save it' : 'Use Save to Oceanum'} to store it on Oceanum.io.`, { autoClose: 5000 });
328
+ }
329
+ catch (error) {
330
+ await reportError('Upload failed', error);
331
+ }
332
+ }
260
333
  async function openFromOceanum() {
261
334
  var _a, _b;
262
335
  if (!store ||
@@ -757,6 +830,23 @@ export const sharePlugin = {
757
830
  await (id ? openById(id) : openFromOceanum());
758
831
  }
759
832
  });
833
+ // Run by the Notebooks tab's Examples; not offered in menus or the palette.
834
+ app.commands.addCommand(CommandIDs.openExample, {
835
+ label: 'Open a copy',
836
+ caption: 'Open a copy of an Oceanum example notebook',
837
+ isEnabled: () => signedIn(),
838
+ execute: async (args) => {
839
+ if (isSpecId(args.id)) {
840
+ await openExample(args.id, typeof args.title === 'string' ? args.title : '');
841
+ }
842
+ }
843
+ });
844
+ app.commands.addCommand(CommandIDs.upload, {
845
+ label: label('Upload Notebook…'),
846
+ caption: 'Upload a notebook from this computer, to save on Oceanum.io',
847
+ isEnabled: () => signedIn(),
848
+ execute: () => uploadNotebook()
849
+ });
760
850
  app.commands.addCommand(CommandIDs.save, {
761
851
  label: label('Save to Oceanum'),
762
852
  caption: 'Save the notebook to Oceanum.io',
@@ -808,6 +898,7 @@ export const sharePlugin = {
808
898
  });
809
899
  const commands = [
810
900
  CommandIDs.open,
901
+ CommandIDs.upload,
811
902
  CommandIDs.save,
812
903
  CommandIDs.share,
813
904
  CommandIDs.rename,
@@ -865,7 +956,9 @@ export const sharePlugin = {
865
956
  });
866
957
  if (oceanumOnly) {
867
958
  void app.restored.then(() => {
868
- hideFileBrowser(app.shell);
959
+ hideFileBrowser(app.shell, OCEANUM_PANEL_ID);
960
+ // Oceanum is where notebooks live now, so its panel leads the sidebar.
961
+ moveToTop(app.shell, OCEANUM_PANEL_ID);
869
962
  // IFileMenu is the narrow interface; the menu itself is a Lumino Menu, which
870
963
  // is what can drop items.
871
964
  const fileMenu = mainMenu === null || mainMenu === void 0 ? void 0 : mainMenu.fileMenu;
@@ -873,6 +966,11 @@ export const sharePlugin = {
873
966
  pruneFileMenu(fileMenu);
874
967
  pruneBeforeEachOpen(fileMenu);
875
968
  }
969
+ const newMenu = mainMenu === null || mainMenu === void 0 ? void 0 : mainMenu.fileMenu.newMenu;
970
+ if (newMenu instanceof Menu) {
971
+ pruneFileMenu(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
972
+ pruneBeforeEachOpen(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
973
+ }
876
974
  routeSaves(tracker, pushQuietly);
877
975
  });
878
976
  }
@@ -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.8.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 in the Notebooks tab, where the site provides them. 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
  />
@@ -3,9 +3,18 @@ import * as React from 'react';
3
3
  import { IOceanumAuth } from './auth/tokens';
4
4
  import { SpecStoreClient } from './share/client';
5
5
  import { notebooksChanged } from './share/events';
6
- import { ISpecSummary, partitionSummaries } from './share/notebook';
6
+ import {
7
+ INotebookDemoItem,
8
+ INotebookDemoSection,
9
+ ISpecSummary,
10
+ parseNotebookDemo,
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,158 @@ export interface IStoredNotebooksProps {
20
45
  onSignIn?: () => void;
21
46
  }
22
47
 
48
+ /** The deployment's Notebook Demo record, which lists the Examples. */
49
+ type Examples =
50
+ | { state: 'none' }
51
+ | { state: 'error'; message: string }
52
+ | { state: 'ready'; sections: INotebookDemoSection[] };
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
+ * Read the Notebook Demo record, if the deployment names one. Never rejects: a failure
61
+ * here is shown in the Examples section and leaves the rest of the tab alone.
62
+ */
63
+ async function loadExamples(
64
+ auth: IOceanumAuth,
65
+ specsUrl: string,
66
+ id: string | null
67
+ ): Promise<Examples> {
68
+ if (!id) {
69
+ return { state: 'none' };
70
+ }
71
+ try {
72
+ const client = new SpecStoreClient({
73
+ specsUrl,
74
+ getAccessToken: () => auth.getAccessToken(),
75
+ specType: 'notebook-demo'
76
+ });
77
+ const record = await client.get(id);
78
+ return { state: 'ready', sections: parseNotebookDemo(record.spec) };
79
+ } catch (error) {
80
+ return {
81
+ state: 'error',
82
+ message:
83
+ error instanceof Error ? error.message : 'Could not reach Oceanum.io.'
84
+ };
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The curated examples. Their rows deliberately leave out SPEC_ID_ATTRIBUTE and the
90
+ * stored-notebook row class: the share plugin's row menu (Open, Rename, Share, Delete)
91
+ * is bound to those, and none of it applies to Oceanum's own example records.
92
+ */
93
+ function ExampleSections({
94
+ examples,
95
+ onOpen,
96
+ show,
97
+ onShowChange
98
+ }: {
99
+ examples: Examples;
100
+ onOpen?: (item: INotebookDemoItem) => void;
101
+ show: boolean | null;
102
+ onShowChange?: (show: boolean) => void;
103
+ }): React.ReactElement | null {
104
+ // Not yet known, or hidden with no switch to bring them back: nothing to show.
105
+ if (examples.state === 'none' || show === null || (!show && !onShowChange)) {
106
+ return null;
107
+ }
108
+ const count =
109
+ examples.state === 'ready'
110
+ ? examples.sections.reduce(
111
+ (total, section) => total + section.items.length,
112
+ 0
113
+ )
114
+ : null;
115
+ if (count === 0) {
116
+ return null;
117
+ }
118
+ const heading = (
119
+ <div className="oceanum-notebooks-heading">
120
+ Examples
121
+ <span className="oceanum-notebooks-heading-end">
122
+ {count !== null && (
123
+ <span className="oceanum-notebooks-count">{count}</span>
124
+ )}
125
+ {onShowChange && (
126
+ <button
127
+ type="button"
128
+ role="switch"
129
+ aria-checked={show}
130
+ aria-label="Show examples"
131
+ title="Show examples"
132
+ className="oceanum-notebooks-switch"
133
+ onClick={() => onShowChange(!show)}
134
+ >
135
+ <span className="oceanum-notebooks-switch-thumb" />
136
+ </button>
137
+ )}
138
+ </span>
139
+ </div>
140
+ );
141
+ // Hidden: the heading stays, so the switch is there to bring them back.
142
+ if (!show) {
143
+ return <div className="oceanum-notebooks-section">{heading}</div>;
144
+ }
145
+ if (examples.state === 'error') {
146
+ return (
147
+ <div className="oceanum-notebooks-section">
148
+ {heading}
149
+ <div className="oceanum-text-error">
150
+ The examples could not be loaded. {examples.message}
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+ return (
156
+ <div className="oceanum-notebooks-section">
157
+ {heading}
158
+ {examples.sections.map((section, index) => (
159
+ <div key={index}>
160
+ {/* Titles come from the record: React escapes them, never set as HTML. */}
161
+ <div className="oceanum-notebooks-subheading">{section.title}</div>
162
+ <ul className="oceanum-notebooks-list">
163
+ {section.items.map((item, position) => {
164
+ const attributes = {
165
+ [EXAMPLE_ID_ATTRIBUTE]: item.id,
166
+ title: item.summary
167
+ };
168
+ const name = (
169
+ <span className="oceanum-notebooks-name">{item.title}</span>
170
+ );
171
+ return (
172
+ // A record may list the same example twice; the id alone is no key.
173
+ <li key={`${position}:${item.id}`}>
174
+ {onOpen ? (
175
+ <button
176
+ type="button"
177
+ className="oceanum-notebooks-example"
178
+ onClick={() => onOpen(item)}
179
+ {...attributes}
180
+ >
181
+ {name}
182
+ </button>
183
+ ) : (
184
+ <div
185
+ className="oceanum-notebooks-example oceanum-notebooks-item-static"
186
+ {...attributes}
187
+ >
188
+ {name}
189
+ </div>
190
+ )}
191
+ </li>
192
+ );
193
+ })}
194
+ </ul>
195
+ </div>
196
+ ))}
197
+ </div>
198
+ );
199
+ }
27
200
 
28
201
  function Section({
29
202
  title,
@@ -90,6 +263,10 @@ function Section({
90
263
  export function StoredNotebooks({
91
264
  auth,
92
265
  onOpen,
266
+ onOpenExample,
267
+ onUpload,
268
+ showExamples = true,
269
+ onShowExamplesChange,
93
270
  onSignIn
94
271
  }: IStoredNotebooksProps): React.ReactElement {
95
272
  const [load, setLoad] = React.useState<Load>({ state: 'loading' });
@@ -115,7 +292,7 @@ export function StoredNotebooks({
115
292
  let cancelled = false;
116
293
  const specs = auth.urls?.specs ?? null;
117
294
  if (!user || !specs) {
118
- setLoad({ state: 'ready', items: [] });
295
+ setLoad({ state: 'ready', items: [], examples: { state: 'none' } });
119
296
  return;
120
297
  }
121
298
  setLoad({ state: 'loading' });
@@ -125,9 +302,14 @@ export function StoredNotebooks({
125
302
  specsUrl: specs,
126
303
  getAccessToken: () => auth.getAccessToken()
127
304
  });
128
- const items = await client.list();
305
+ // Only the record the deployment names: every signed-in user can create
306
+ // notebook-demo records, so the examples never come from a listing.
307
+ const [items, examples] = await Promise.all([
308
+ client.list(),
309
+ loadExamples(auth, specs, auth.environment?.notebookDemo ?? null)
310
+ ]);
129
311
  if (!cancelled) {
130
- setLoad({ state: 'ready', items });
312
+ setLoad({ state: 'ready', items, examples });
131
313
  }
132
314
  } catch (error) {
133
315
  if (!cancelled) {
@@ -187,8 +369,30 @@ export function StoredNotebooks({
187
369
  }
188
370
 
189
371
  const { mine, shared } = partitionSummaries(load.items, user.email);
372
+ // A public example is readable by everyone, so the listing returns it as shared with
373
+ // every signed-in user; it is shown under Examples instead.
374
+ const exampleIds = new Set(
375
+ load.examples.state === 'ready'
376
+ ? load.examples.sections.flatMap(section =>
377
+ section.items.map(item => item.id)
378
+ )
379
+ : []
380
+ );
190
381
  return (
191
382
  <div className="oceanum-notebooks">
383
+ {/* Without a spec store the upload command does nothing. */}
384
+ {onUpload && auth.urls?.specs ? (
385
+ <div className="oceanum-notebooks-actions">
386
+ <button
387
+ type="button"
388
+ className="oceanum-notebooks-upload"
389
+ title="Upload a notebook from this computer, to save on Oceanum.io"
390
+ onClick={onUpload}
391
+ >
392
+ Upload notebook…
393
+ </button>
394
+ </div>
395
+ ) : null}
192
396
  <Section
193
397
  title="My notebooks"
194
398
  items={mine}
@@ -197,10 +401,16 @@ export function StoredNotebooks({
197
401
  />
198
402
  <Section
199
403
  title="Shared with me"
200
- items={shared}
404
+ items={shared.filter(item => !exampleIds.has(item.id))}
201
405
  empty="No notebooks have been shared with you."
202
406
  onOpen={onOpen}
203
407
  />
408
+ <ExampleSections
409
+ examples={load.examples}
410
+ onOpen={onOpenExample}
411
+ show={showExamples}
412
+ onShowChange={onShowExamplesChange}
413
+ />
204
414
  </div>
205
415
  );
206
416
  }