@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/README.md CHANGED
@@ -19,6 +19,62 @@ To install the extension, execute:
19
19
  pip install oceanumlab
20
20
  ```
21
21
 
22
+ ## Oceanum.io sign-in
23
+
24
+ A **Sign in to Oceanum.io** control sits in the top bar. Signing in gives the Notebooks tab
25
+ your stored notebooks and lets Oceanum AI act as you, with no Datamesh token to paste.
26
+
27
+ It uses the OAuth **device grant**, run by oceanumlab's Jupyter server extension: click
28
+ **Sign in**, open the link it shows, check the code matches, and sign in on Oceanum.io as
29
+ usual. The dialog closes by itself when you are done. It works with no configuration, and
30
+ from any address — `localhost` on any port, a JupyterHub, a remote server.
31
+
32
+ That last point is why it works this way. A browser sign-in (a popup, a redirect, the
33
+ Oceanum widget) has to tell Auth0 which page to return to, Auth0 only accepts addresses
34
+ registered in advance, and it cannot wildcard a port or an arbitrary host. A JupyterLab's
35
+ address is not knowable ahead of time, so on anything but Oceanum's own sites a browser
36
+ sign-in fails with "Callback URL mismatch". The device grant has no callback.
37
+
38
+ The refresh token stays in the Jupyter server's memory and never reaches the page or the
39
+ disk, so restarting the server signs you out.
40
+
41
+ ### Configuration
42
+
43
+ Sign-in is server configuration, not a frontend setting: which Auth0 tenant a notebook signs
44
+ in to, and which services its tokens are sent to, is the deployment's decision, and a
45
+ user-editable setting could be pointed at someone else's. In `jupyter_server_config.py` —
46
+ `~/.jupyter/` for one user, or `etc/jupyter/` under the environment prefix for everyone
47
+ using it; `jupyter --paths` lists both:
48
+
49
+ ```python
50
+ c.OceanumLab.sign_in = "off" # "device" is the default
51
+ ```
52
+
53
+ To sign in to a deployment other than Oceanum production, replace the environment whole:
54
+
55
+ ```python
56
+ c.OceanumLab.device_environment = {
57
+ "auth0Domain": "auth.example.org",
58
+ "clientId": "<an Auth0 Native application with the Device Code grant>",
59
+ "audience": "",
60
+ "oceanumDomain": "example.org",
61
+ "urls": {
62
+ "datamesh": "https://datamesh.example.org",
63
+ "specs": "https://specs.example.org",
64
+ "manage": "https://manage.example.org",
65
+ },
66
+ }
67
+ ```
68
+
69
+ **This file is Python, not JSON** — `True`, not `true`. A syntax error there is quiet and
70
+ expensive: traitlets logs it once at startup and then skips the **whole** file, so the
71
+ setting silently does not apply. Check the server's startup log for
72
+ `oceanumlab: Oceanum.io sign-in by device code`.
73
+
74
+ notebook.oceanum.io is a JupyterLite site with no Jupyter server, so none of this applies
75
+ there. It signs in with the Oceanum widget, from its own extension, and lists
76
+ `@oceanum/oceanumlab:device-auth` under `disabledExtensions`.
77
+
22
78
  ## Uninstall
23
79
 
24
80
  To remove the extension, execute:
@@ -31,27 +87,59 @@ pip uninstall oceanumlab
31
87
 
32
88
  ### Development install
33
89
 
34
- Note: You will need NodeJS 22.12 or later (or 20.19+) to build the extension package.
90
+ The extension is built with [jupyter-builder](https://github.com/jupyterlab/jupyter-builder),
91
+ which bundles it with rspack. You will need:
92
+
93
+ - Python 3.10 or later
94
+ - Node.js 22.12 or later (or 20.19+), which rspack requires
35
95
 
36
- The `jlpm` command is JupyterLab's pinned version of
37
- [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use
38
- `yarn` or `npm` in lieu of `jlpm` below.
96
+ The `jlpm` command is a pinned version of [yarn](https://yarnpkg.com/) that is
97
+ installed with jupyter-builder. You may use `yarn` or `npm` in lieu of `jlpm` below.
98
+
99
+ Create an environment, either with conda, which also installs Node.js and jupyter-builder:
39
100
 
40
101
  ```bash
41
102
  # Clone the repo to your local environment
42
103
  # Change directory to the oceanumlab directory
43
- # Install package in development mode, with jupyter-builder for building the extension
44
- pip install -e ".[dev]"
104
+ conda env create -f environment.yml
105
+ conda activate oceanumlab
106
+ ```
107
+
108
+ or with a virtual environment, using your own Node.js:
109
+
110
+ ```bash
111
+ python -m venv .venv
112
+ source .venv/bin/activate
113
+ ```
114
+
115
+ Then install the extension in development mode:
116
+
117
+ ```bash
118
+ # Install the package in development mode. The dev extra installs jupyter-builder
119
+ # and the test extra installs the server test dependencies. This also enables the
120
+ # server extension.
121
+ pip install -e ".[dev,test]"
45
122
  # Link your development version of the extension with JupyterLab
46
123
  jupyter-builder develop . --overwrite
47
- # Rebuild extension Typescript source after making changes
124
+ # Rebuild the extension after making changes
125
+ # Unlike the steps above, which you only do once, do this every time you make a change
48
126
  jlpm build
49
127
  ```
50
128
 
129
+ Every `pip install -e` puts a copy of the built extension back in place of the link,
130
+ so run `jupyter-builder develop . --overwrite` again after reinstalling.
131
+
132
+ The main build commands are:
133
+
134
+ - `jlpm build`: development build, with source maps
135
+ - `jlpm build:prod`: production build, as used for releases
136
+ - `jlpm clean:all`: remove the built TypeScript, the built extension and the lint caches
137
+
51
138
  You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension.
52
139
 
53
140
  ```bash
54
141
  # Watch the source directory in one terminal, automatically rebuilding when needed
142
+ # (runs tsc and jupyter-builder in watch mode)
55
143
  jlpm watch
56
144
  # Run JupyterLab in another terminal
57
145
  jupyter lab
@@ -71,9 +159,9 @@ jupyter lab build --minimize=False
71
159
  pip uninstall oceanumlab
72
160
  ```
73
161
 
74
- In development mode, you will also need to remove the symlink created by `jupyter-builder develop`
162
+ In development mode, you will also need to remove the symlink created by the `jupyter-builder develop`
75
163
  command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions`
76
- folder is located. Then you can remove the symlink named `oceanumlab` within that folder.
164
+ folder is located. Then you can remove the symlink named `@oceanum/oceanumlab` within that folder.
77
165
 
78
166
  ### Testing the extension
79
167
 
@@ -81,10 +169,13 @@ folder is located. Then you can remove the symlink named `oceanumlab` within tha
81
169
 
82
170
  This extension is using [Pytest](https://docs.pytest.org/) for Python code testing.
83
171
 
84
- Install test dependencies (needed only once):
172
+ The test dependencies are installed by `pip install -e ".[dev,test]"` above. If you
173
+ installed without the test extra, install them (needed only once) and restore the
174
+ front-end extension link:
85
175
 
86
176
  ```sh
87
- pip install -e ".[test]"
177
+ pip install -e ".[dev,test]"
178
+ jupyter-builder develop . --overwrite
88
179
  ```
89
180
 
90
181
  To execute them, run:
@@ -2,13 +2,8 @@ import React from 'react';
2
2
  import { ReactWidget } from '@jupyterlab/apputils';
3
3
  export declare class DatameshUI extends ReactWidget {
4
4
  private _url;
5
- /** `url` is the `datameshUiUrl` setting. */
5
+ /** `url` is the deployment's Datamesh UI address (the sign-in environment's). */
6
6
  constructor(_url: string);
7
- /**
8
- * Point the panel at the `datameshUiUrl` setting's new value. The iframe
9
- * reloads only if its address actually changes.
10
- */
11
- set url(url: string);
12
7
  /** The iframe's window, for telling its messages apart; `null` if none. */
13
8
  get frame(): Window | null;
14
9
  render(): React.ReactElement;
package/lib/DatameshUI.js CHANGED
@@ -2,19 +2,11 @@ import React from 'react';
2
2
  import { ReactWidget } from '@jupyterlab/apputils';
3
3
  import { datameshUiSrc } from './datameshUiUrl';
4
4
  export class DatameshUI extends ReactWidget {
5
- /** `url` is the `datameshUiUrl` setting. */
5
+ /** `url` is the deployment's Datamesh UI address (the sign-in environment's). */
6
6
  constructor(_url) {
7
7
  super();
8
8
  this._url = _url;
9
9
  }
10
- /**
11
- * Point the panel at the `datameshUiUrl` setting's new value. The iframe
12
- * reloads only if its address actually changes.
13
- */
14
- set url(url) {
15
- this._url = url;
16
- this.update();
17
- }
18
10
  /** The iframe's window, for telling its messages apart; `null` if none. */
19
11
  get frame() {
20
12
  var _a, _b;
@@ -5,6 +5,7 @@ import { CommandRegistry } from '@lumino/commands';
5
5
  import { ISignal, Signal } from '@lumino/signaling';
6
6
  import { Widget } from '@lumino/widgets';
7
7
  import React from 'react';
8
+ import { IOceanumAuth } from './auth/tokens';
8
9
  export interface IDatasource {
9
10
  id: string;
10
11
  label: string;
@@ -40,18 +41,22 @@ export interface IDatameshWidgetProps {
40
41
  name: string;
41
42
  icon: LabIcon;
42
43
  openDatameshUI: any;
43
- /** The `datameshUiUrl` setting. */
44
+ /** The deployment's Datamesh UI address. */
44
45
  datameshUiUrl: () => string;
45
46
  /** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
46
47
  datameshUiFrame: () => Window | null;
47
48
  /** The `datameshToken` setting. */
48
49
  datameshToken: () => string;
49
- /** The `aiBackendUrl` setting. */
50
+ /** The deployment's Oceanum AI address. */
50
51
  aiBackendUrl: () => string;
51
52
  /** Emitted when the settings change. */
52
53
  settingsChanged: ISignal<unknown, void>;
53
54
  commands: CommandRegistry;
54
55
  getCurrentWidget: () => Widget;
56
+ /** Oceanum.io sign-in, or null on a host with no Oceanum environment. */
57
+ auth?: IOceanumAuth | null;
58
+ /** Open a stored notebook by spec store id. */
59
+ openStoredNotebook?: (id: string) => void;
55
60
  }
56
61
  /**
57
62
  * A widget for Datamesh Connections.
@@ -65,5 +70,16 @@ export declare class DatameshConnectWidget extends ReactWidget {
65
70
  constructor(props: IDatameshWidgetProps);
66
71
  receiveIFrameMessage(event: MessageEvent): void;
67
72
  renderDisplay(datameshWorkspace: IWorkspaceSpec): React.ReactElement;
73
+ /** The Datamesh workspace tab: what this panel showed above the divider. */
74
+ renderDatamesh(): React.ReactElement;
68
75
  render(): React.ReactElement;
76
+ /**
77
+ * The visible tab. The widget only holds it and announces changes through
78
+ * `tabChanged`; the plugin persists it in IStateDB, because the layout restorer
79
+ * restores a widget's place in the shell rather than fields on it.
80
+ */
81
+ get selectedTab(): string;
82
+ selectTab(id: string): void;
83
+ readonly tabChanged: Signal<this, string>;
84
+ private _selectedTab;
69
85
  }
@@ -9,7 +9,10 @@ import { Signal } from '@lumino/signaling';
9
9
  import React from 'react';
10
10
  import { marked } from 'marked';
11
11
  import { DatasourceItem } from './DatasourceItem';
12
- import { SIGN_IN_COMMAND, aiBackendUrl, aiCredentialSource, canSignIn, pastedToken, resolveAiCredential, signInToken } from './aiBackend';
12
+ import { startSignIn } from './auth/startSignIn';
13
+ import { StoredNotebooks } from './StoredNotebooks';
14
+ import { Tabs } from './Tabs';
15
+ import { SIGN_IN_COMMAND, aiBackendUrl, aiCredentialSource, canSignIn, isSignedIn, pastedToken, resolveAiCredential, signInToken } from './aiBackend';
13
16
  import { isDatameshUiMessage } from './datameshUiUrl';
14
17
  import { describeProgress, onProgress } from './progress';
15
18
  // Configure marked for safe rendering
@@ -290,6 +293,7 @@ function readAiAccess(commands, settings) {
290
293
  source: aiCredentialSource(datameshToken, commands),
291
294
  pasted: pastedToken(datameshToken),
292
295
  canSignIn: canSignIn(commands),
296
+ signedIn: isSignedIn(commands),
293
297
  backend: aiBackendUrl(settings.aiBackendUrl())
294
298
  };
295
299
  }
@@ -342,9 +346,11 @@ function TokenConfigMessage({ commands, settings }) {
342
346
  "."));
343
347
  }
344
348
  return (React.createElement("div", { className: "oceanum-token-config" },
345
- "Set your ",
346
- React.createElement("a", { onClick: openSettings }, "Datamesh token"),
347
- " to enable Oceanum.io services",
349
+ "Set your",
350
+ ' ',
351
+ React.createElement("a", { onClick: () => commands.execute('settingeditor:open', { query: 'Oceanum' }) }, "Datamesh token"),
352
+ ' ',
353
+ "to enable Oceanum.io services",
348
354
  ' ',
349
355
  React.createElement("a", { href: "https://home.oceanum.io/account", target: "_blank", rel: "noopener noreferrer" }, "Get token here")));
350
356
  }
@@ -596,6 +602,44 @@ function AIChatPanel({ commands, settings }) {
596
602
  ? commands.execute('oceanum-ai:stop')
597
603
  : handleSubmit()), disabled: !loading && !input.trim(), title: loading ? 'Stop the current response' : undefined }, loading ? 'Stop' : 'Send'))));
598
604
  }
605
+ /**
606
+ * The panel's tabs.
607
+ *
608
+ * A component rather than a list built in `render`, because whether Oceanum AI is
609
+ * reachable is not static: `useAiAccess` re-reads it when the settings change and when
610
+ * the host signals a sign-in or sign-out. An AI tab is only offered when the chat has a
611
+ * credential to work with — otherwise it would open on the same nothing the panel
612
+ * rendered before there were tabs, except now with a label promising otherwise.
613
+ */
614
+ function PanelTabs({ auth, commands, settings, renderDatamesh, openStoredNotebook, selected, onSelect }) {
615
+ const access = useAiAccess(commands, settings);
616
+ const tabs = [
617
+ {
618
+ id: 'notebooks',
619
+ label: 'Notebooks',
620
+ render: () =>
621
+ // An auth with no environment is a provider that has nothing to sign in to (the
622
+ // server has sign-in turned off). Asking the user to sign in would be a dead end.
623
+ (auth === null || auth === void 0 ? void 0 : auth.environment) ? (React.createElement(StoredNotebooks, { auth: auth, onOpen: openStoredNotebook && (item => openStoredNotebook(item.id)), onSignIn: () => {
624
+ startSignIn(commands, auth).catch(error => {
625
+ console.warn('Oceanum.io sign-in could not start.', error);
626
+ });
627
+ } })) : (React.createElement("div", { className: "oceanum-text-empty" }, "Oceanum.io sign-in is not configured for this host."))
628
+ },
629
+ { id: 'datamesh', label: 'Datamesh', render: renderDatamesh }
630
+ ];
631
+ // Signed in, not merely holding a credential: a Datamesh token pasted into the
632
+ // settings would reach the backend, but the tab stays out of the way until someone
633
+ // has signed in to Oceanum.io.
634
+ if (access.signedIn) {
635
+ tabs.push({
636
+ id: 'ai',
637
+ label: 'Oceanum AI',
638
+ render: () => React.createElement(AIChatPanel, { commands: commands, settings: settings })
639
+ });
640
+ }
641
+ return React.createElement(Tabs, { tabs: tabs, selected: selected, onSelect: onSelect });
642
+ }
599
643
  /**
600
644
  * A widget for Datamesh Connections.
601
645
  */
@@ -603,6 +647,10 @@ export class DatameshConnectWidget extends ReactWidget {
603
647
  constructor(props) {
604
648
  super();
605
649
  this.datameshWorkspaceSpec = null;
650
+ this.tabChanged = new Signal(this);
651
+ // Notebooks first: on notebook.oceanum.io this panel is how a user reaches their
652
+ // work, so it opens there rather than on Datamesh.
653
+ this._selectedTab = 'notebooks';
606
654
  this.props = props;
607
655
  this.renderSignal = new Signal(this);
608
656
  this.renderDisplay = this.renderDisplay.bind(this);
@@ -628,18 +676,38 @@ export class DatameshConnectWidget extends ReactWidget {
628
676
  React.createElement("a", { onClick: this.props.openDatameshUI }, "Open"),
629
677
  " the Oceanum Datamesh UI to add datasources."))));
630
678
  }
631
- render() {
632
- return (React.createElement("div", { className: "datamesh-connect" },
633
- React.createElement("header", { className: "oceanum-sidebar-header" },
634
- React.createElement(this.props.icon.react, { tag: "span", width: "auto", height: "20px", verticalAlign: "middle" }),
635
- React.createElement("span", { className: "oceanum-sidebar-title" }, "Oceanum.io")),
679
+ /** The Datamesh workspace tab: what this panel showed above the divider. */
680
+ renderDatamesh() {
681
+ return (React.createElement(React.Fragment, null,
636
682
  React.createElement("div", { className: "datamesh-workspace-header" },
637
683
  React.createElement("span", null, "Datamesh Workspace"),
638
684
  React.createElement("div", { className: "open-datamesh-ui", onClick: this.props.openDatameshUI, title: "Open Datamesh UI" }, React.createElement(addIcon.react, { height: "20px", verticalAlign: "middle" }))),
639
685
  React.createElement("div", { className: "datamesh-connect-workspace" },
640
686
  React.createElement(UseSignal, { signal: this.renderSignal, initialArgs: null }, (_, datameshWorkspace) => this.renderDisplay(datameshWorkspace)),
641
- React.createElement(TokenConfigMessage, { commands: this.props.commands, settings: this.props })),
642
- React.createElement("div", { className: "datamesh-connect-divider" }),
643
- React.createElement(AIChatPanel, { commands: this.props.commands, settings: this.props })));
687
+ React.createElement(TokenConfigMessage, { commands: this.props.commands, settings: this.props }))));
688
+ }
689
+ render() {
690
+ return (React.createElement("div", { className: "datamesh-connect" },
691
+ React.createElement("header", { className: "oceanum-sidebar-header" },
692
+ React.createElement(this.props.icon.react, { tag: "span", width: "auto", height: "20px", verticalAlign: "middle" }),
693
+ React.createElement("span", { className: "oceanum-sidebar-title" }, "Oceanum.io")),
694
+ React.createElement(UseSignal, { signal: this.tabChanged, initialArgs: this.selectedTab }, () => {
695
+ var _a;
696
+ return (React.createElement(PanelTabs, { auth: (_a = this.props.auth) !== null && _a !== void 0 ? _a : null, commands: this.props.commands, settings: this.props, renderDatamesh: () => this.renderDatamesh(), openStoredNotebook: this.props.openStoredNotebook, selected: this.selectedTab, onSelect: id => this.selectTab(id) }));
697
+ })));
698
+ }
699
+ /**
700
+ * The visible tab. The widget only holds it and announces changes through
701
+ * `tabChanged`; the plugin persists it in IStateDB, because the layout restorer
702
+ * restores a widget's place in the shell rather than fields on it.
703
+ */
704
+ get selectedTab() {
705
+ return this._selectedTab;
706
+ }
707
+ selectTab(id) {
708
+ if (id !== this._selectedTab) {
709
+ this._selectedTab = id;
710
+ this.tabChanged.emit(id);
711
+ }
644
712
  }
645
713
  }
@@ -0,0 +1,20 @@
1
+ import * as React from 'react';
2
+ import { IOceanumAuth } from './auth/tokens';
3
+ import { ISpecSummary } from './share/notebook';
4
+ export interface IStoredNotebooksProps {
5
+ auth: IOceanumAuth;
6
+ /**
7
+ * Open a stored notebook. Undefined where nothing has registered a way to open one;
8
+ * the list then reads rather than offering a click that does nothing.
9
+ */
10
+ onOpen?: (item: ISpecSummary) => void;
11
+ /**
12
+ * Start signing in the way this host shows it; without it the auth's own `signIn()`
13
+ * is used, which on a JupyterLab server shows no code dialog.
14
+ */
15
+ onSignIn?: () => void;
16
+ }
17
+ /**
18
+ * The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
19
+ */
20
+ export declare function StoredNotebooks({ auth, onOpen, onSignIn }: IStoredNotebooksProps): React.ReactElement;
@@ -0,0 +1,112 @@
1
+ import * as React from 'react';
2
+ import { SpecStoreClient } from './share/client';
3
+ import { notebooksChanged } from './share/events';
4
+ import { partitionSummaries } from './share/notebook';
5
+ import { SPEC_ID_ATTRIBUTE } from './share/plugin';
6
+ function Section({ title, items, empty, onOpen }) {
7
+ // Each row carries its record id so the share plugin's context menu (Open, Rename,
8
+ // Share, Delete) can tell which one was clicked.
9
+ const rowAttributes = (item) => {
10
+ var _a;
11
+ return ({
12
+ [SPEC_ID_ATTRIBUTE]: item.id,
13
+ title: (_a = item.description) !== null && _a !== void 0 ? _a : undefined
14
+ });
15
+ };
16
+ return (React.createElement("div", { className: "oceanum-notebooks-section" },
17
+ React.createElement("div", { className: "oceanum-notebooks-heading" },
18
+ title,
19
+ React.createElement("span", { className: "oceanum-notebooks-count" }, items.length)),
20
+ items.length === 0 ? (React.createElement("div", { className: "oceanum-text-empty" }, empty)) : (React.createElement("ul", { className: "oceanum-notebooks-list" }, items.map(item => (React.createElement("li", { key: item.id }, onOpen ? (React.createElement("button", { type: "button", className: "oceanum-notebooks-item", onClick: () => onOpen(item), ...rowAttributes(item) },
21
+ React.createElement("span", { className: "oceanum-notebooks-name" }, item.name || 'Untitled'))) : (React.createElement("div", { className: "oceanum-notebooks-item oceanum-notebooks-item-static", ...rowAttributes(item) },
22
+ React.createElement("span", { className: "oceanum-notebooks-name" }, item.name || 'Untitled'))))))))));
23
+ }
24
+ /**
25
+ * The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
26
+ */
27
+ export function StoredNotebooks({ auth, onOpen, onSignIn }) {
28
+ const [load, setLoad] = React.useState({ state: 'loading' });
29
+ const [user, setUser] = React.useState(auth.user);
30
+ // Bumped whenever the store changes, so the list is fetched again.
31
+ const [version, setVersion] = React.useState(0);
32
+ // Reload whenever the signed-in user changes, so signing in or out does not leave a
33
+ // stale list from the previous session on screen; and whenever this extension has
34
+ // created, renamed or deleted a record.
35
+ React.useEffect(() => {
36
+ const onUser = () => setUser(auth.user);
37
+ const onChanged = () => setVersion(v => v + 1);
38
+ auth.userChanged.connect(onUser);
39
+ notebooksChanged.connect(onChanged);
40
+ return () => {
41
+ auth.userChanged.disconnect(onUser);
42
+ notebooksChanged.disconnect(onChanged);
43
+ };
44
+ }, [auth]);
45
+ React.useEffect(() => {
46
+ var _a, _b;
47
+ let cancelled = false;
48
+ const specs = (_b = (_a = auth.urls) === null || _a === void 0 ? void 0 : _a.specs) !== null && _b !== void 0 ? _b : null;
49
+ if (!user || !specs) {
50
+ setLoad({ state: 'ready', items: [] });
51
+ return;
52
+ }
53
+ setLoad({ state: 'loading' });
54
+ void (async () => {
55
+ try {
56
+ const client = new SpecStoreClient({
57
+ specsUrl: specs,
58
+ getAccessToken: () => auth.getAccessToken()
59
+ });
60
+ const items = await client.list();
61
+ if (!cancelled) {
62
+ setLoad({ state: 'ready', items });
63
+ }
64
+ }
65
+ catch (error) {
66
+ if (!cancelled) {
67
+ setLoad({
68
+ state: 'error',
69
+ message: error instanceof Error
70
+ ? error.message
71
+ : 'Could not reach Oceanum.io.'
72
+ });
73
+ }
74
+ }
75
+ })();
76
+ return () => {
77
+ cancelled = true;
78
+ };
79
+ }, [auth, user, version]);
80
+ if (!user) {
81
+ const signIn = () => {
82
+ if (onSignIn) {
83
+ onSignIn();
84
+ return;
85
+ }
86
+ auth.signIn().catch(error => {
87
+ console.warn('Oceanum.io sign-in could not start.', error);
88
+ });
89
+ };
90
+ return (React.createElement("div", { className: "oceanum-text-empty" },
91
+ React.createElement("a", { role: "button", tabIndex: 0, onClick: signIn, onKeyDown: event => {
92
+ if (event.key === 'Enter' || event.key === ' ') {
93
+ event.preventDefault();
94
+ signIn();
95
+ }
96
+ } }, "Sign in"),
97
+ ' ',
98
+ "to Oceanum.io to see your notebooks."));
99
+ }
100
+ if (load.state === 'loading') {
101
+ return React.createElement("div", { className: "oceanum-text-empty" }, "Loading\u2026");
102
+ }
103
+ if (load.state === 'error') {
104
+ // Never render the sections alongside an error: their empty messages would claim
105
+ // the account has no notebooks when the load simply failed.
106
+ return React.createElement("div", { className: "oceanum-text-error" }, load.message);
107
+ }
108
+ const { mine, shared } = partitionSummaries(load.items, user.email);
109
+ return (React.createElement("div", { className: "oceanum-notebooks" },
110
+ React.createElement(Section, { title: "My notebooks", items: mine, empty: "You have not saved any notebooks yet.", onOpen: onOpen }),
111
+ React.createElement(Section, { title: "Shared with me", items: shared, empty: "No notebooks have been shared with you.", onOpen: onOpen })));
112
+ }
package/lib/Tabs.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import * as React from 'react';
2
+ export interface ITab {
3
+ /** Stable key, also used as the restored selection value. */
4
+ id: string;
5
+ label: string;
6
+ render: () => React.ReactElement;
7
+ }
8
+ export interface ITabsProps {
9
+ tabs: readonly ITab[];
10
+ /** Selected tab id. Falls back to the first tab when it names no tab. */
11
+ selected: string;
12
+ onSelect: (id: string) => void;
13
+ }
14
+ /**
15
+ * A tab strip over a single visible pane.
16
+ *
17
+ * JupyterLab users work from the keyboard, so the strip follows the WAI-ARIA tabs
18
+ * pattern: one tab stop for the whole strip, arrows to move between tabs, Home and End
19
+ * to reach the ends.
20
+ *
21
+ * Every pane stays mounted and the inactive ones are hidden. Unmounting would be
22
+ * cheaper, but the AI pane holds the conversation in component state and the Datamesh
23
+ * pane holds a workspace subscription, so switching tabs would silently discard a chat
24
+ * the user is in the middle of.
25
+ */
26
+ export declare function Tabs({ tabs, selected, onSelect }: ITabsProps): React.ReactElement;
package/lib/Tabs.js ADDED
@@ -0,0 +1,45 @@
1
+ import * as React from 'react';
2
+ /**
3
+ * A tab strip over a single visible pane.
4
+ *
5
+ * JupyterLab users work from the keyboard, so the strip follows the WAI-ARIA tabs
6
+ * pattern: one tab stop for the whole strip, arrows to move between tabs, Home and End
7
+ * to reach the ends.
8
+ *
9
+ * Every pane stays mounted and the inactive ones are hidden. Unmounting would be
10
+ * cheaper, but the AI pane holds the conversation in component state and the Datamesh
11
+ * pane holds a workspace subscription, so switching tabs would silently discard a chat
12
+ * the user is in the middle of.
13
+ */
14
+ export function Tabs({ tabs, selected, onSelect }) {
15
+ const index = Math.max(0, tabs.findIndex(tab => tab.id === selected));
16
+ const active = tabs[index];
17
+ const onKeyDown = (event) => {
18
+ const last = tabs.length - 1;
19
+ let next = null;
20
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
21
+ next = index === last ? 0 : index + 1;
22
+ }
23
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
24
+ next = index === 0 ? last : index - 1;
25
+ }
26
+ else if (event.key === 'Home') {
27
+ next = 0;
28
+ }
29
+ else if (event.key === 'End') {
30
+ next = last;
31
+ }
32
+ if (next === null) {
33
+ return;
34
+ }
35
+ // Otherwise the arrow keys also scroll the panel.
36
+ event.preventDefault();
37
+ onSelect(tabs[next].id);
38
+ };
39
+ return (React.createElement("div", { className: "oceanum-tabs" },
40
+ React.createElement("div", { className: "oceanum-tabs-strip", role: "tablist", "aria-label": "Oceanum.io" }, tabs.map((tab, i) => (React.createElement("button", { key: tab.id, id: `oceanum-tab-${tab.id}`, className: i === index ? 'oceanum-tab oceanum-tab-selected' : 'oceanum-tab', role: "tab", type: "button", "aria-selected": i === index, "aria-controls": `oceanum-tabpanel-${tab.id}`,
41
+ // Only the selected tab is in the tab order, so Tab moves past the strip
42
+ // rather than through every tab in it.
43
+ tabIndex: i === index ? 0 : -1, onClick: () => onSelect(tab.id), onKeyDown: onKeyDown }, tab.label)))),
44
+ tabs.map(tab => (React.createElement("div", { key: tab.id, className: "oceanum-tabpanel", id: `oceanum-tabpanel-${tab.id}`, role: "tabpanel", "aria-labelledby": `oceanum-tab-${tab.id}`, hidden: tab.id !== active.id }, tab.render())))));
45
+ }
@@ -35,7 +35,7 @@ export interface IAiCredential {
35
35
  headers: Record<string, string>;
36
36
  }
37
37
  /**
38
- * The backend address for an `aiBackendUrl` setting value, without a trailing
38
+ * The backend address for a configured value, without a trailing
39
39
  * slash so a path can be appended. An invalid value falls back to the default.
40
40
  */
41
41
  export declare function aiBackendUrl(value: string | null | undefined): string;
package/lib/aiBackend.js CHANGED
@@ -14,7 +14,7 @@ export const SIGN_IN_COMMAND = 'oceanum-auth:sign-in';
14
14
  /** The host's command that signs out. Visible only while signed in. */
15
15
  export const SIGN_OUT_COMMAND = 'oceanum-auth:sign-out';
16
16
  /**
17
- * The backend address for an `aiBackendUrl` setting value, without a trailing
17
+ * The backend address for a configured value, without a trailing
18
18
  * slash so a path can be appended. An invalid value falls back to the default.
19
19
  */
20
20
  export function aiBackendUrl(value) {
@@ -0,0 +1,35 @@
1
+ import { CommandRegistry } from '@lumino/commands';
2
+ import { Widget } from '@lumino/widgets';
3
+ import { IPendingSignIn, ServerAuth } from './serverAuth';
4
+ export declare const CommandIDs: {
5
+ readonly signIn: "oceanum-auth:sign-in";
6
+ readonly signOut: "oceanum-auth:sign-out";
7
+ readonly account: "oceanum-auth:account";
8
+ /**
9
+ * For other parts of oceanumlab and other extensions, such as the AI chat: resolves to
10
+ * the current access token, or `null` when signed out. Never rejects.
11
+ */
12
+ readonly accessToken: "oceanum-auth:access-token";
13
+ };
14
+ /**
15
+ * The top-bar account control: "Sign in" when signed out, the user's email with a sign-out
16
+ * menu when signed in.
17
+ */
18
+ export declare class AccountWidget extends Widget {
19
+ constructor(auth: ServerAuth, commands: CommandRegistry);
20
+ dispose(): void;
21
+ private _onClick;
22
+ private _render;
23
+ private _auth;
24
+ private _commands;
25
+ private _menu;
26
+ }
27
+ /**
28
+ * What the sign-in dialog shows: the code to confirm, and a link to the page that confirms
29
+ * it. A real link rather than `window.open`, which a popup blocker stops once there has
30
+ * been an `await` between the click and the call -- and there always has, because the code
31
+ * comes from the server.
32
+ */
33
+ export declare class SignInBody extends Widget {
34
+ constructor(pending: IPendingSignIn);
35
+ }