@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
@@ -0,0 +1,25 @@
1
+ import { Dialog } from '@jupyterlab/apputils';
2
+ import { Widget } from '@lumino/widgets';
3
+ import type { ShareChoice } from './client';
4
+ import { ISpecSummary } from './notebook';
5
+ /** Dialog body listing notebooks to open; its value is the selected record id. */
6
+ export declare class NotebookListBody extends Widget implements Dialog.IBodyWidget<string | null> {
7
+ constructor(mine: readonly ISpecSummary[], shared: readonly ISpecSummary[]);
8
+ getValue(): string | null;
9
+ private _addSection;
10
+ private _select;
11
+ private _selected;
12
+ }
13
+ export declare const EVERYONE_WARNING: string;
14
+ /** Dialog body for sharing; its value is the change to make, or `null` if incomplete. */
15
+ export declare class ShareBody extends Widget implements Dialog.IBodyWidget<ShareChoice | null> {
16
+ constructor(link: string);
17
+ getValue(): ShareChoice | null;
18
+ private _radio;
19
+ private static _count;
20
+ private readonly _person;
21
+ private readonly _everyone;
22
+ private readonly _stopEveryone;
23
+ private readonly _email;
24
+ private readonly _level;
25
+ }
@@ -0,0 +1,154 @@
1
+ import { Clipboard } from '@jupyterlab/apputils';
2
+ import { Widget } from '@lumino/widgets';
3
+ import { parseShareEmails, parseTimestamp } from './notebook';
4
+ function element(tag, className, text) {
5
+ const node = document.createElement(tag);
6
+ node.className = className;
7
+ if (text !== undefined) {
8
+ // Names and emails come from other users: always set text, never HTML.
9
+ node.textContent = text;
10
+ }
11
+ return node;
12
+ }
13
+ /** Dialog body listing notebooks to open; its value is the selected record id. */
14
+ export class NotebookListBody extends Widget {
15
+ constructor(mine, shared) {
16
+ super();
17
+ this._selected = null;
18
+ this.addClass('jp-OceanumShare-body');
19
+ this._addSection('My notebooks', mine, false);
20
+ this._addSection('Shared with me', shared, true);
21
+ }
22
+ getValue() {
23
+ return this._selected;
24
+ }
25
+ _addSection(title, items, showCreator) {
26
+ this.node.appendChild(element('h3', 'jp-OceanumShare-heading', title));
27
+ if (items.length === 0) {
28
+ this.node.appendChild(element('p', 'jp-OceanumShare-empty', 'None'));
29
+ return;
30
+ }
31
+ const list = element('ul', 'jp-OceanumShare-list');
32
+ list.setAttribute('role', 'listbox');
33
+ list.setAttribute('aria-label', title);
34
+ for (const item of items) {
35
+ const option = element('li', 'jp-OceanumShare-item');
36
+ option.setAttribute('role', 'option');
37
+ option.setAttribute('aria-selected', 'false');
38
+ option.tabIndex = 0;
39
+ option.appendChild(element('span', 'jp-OceanumShare-name', item.name));
40
+ const modified = parseTimestamp(item.modified);
41
+ const details = [
42
+ modified ? `Modified ${modified.toLocaleDateString()}` : '',
43
+ showCreator && item.creator ? item.creator : ''
44
+ ].filter(Boolean);
45
+ option.appendChild(element('span', 'jp-OceanumShare-meta', details.join(' · ')));
46
+ const select = () => this._select(item.id, option);
47
+ option.addEventListener('click', select);
48
+ option.addEventListener('focus', select);
49
+ list.appendChild(option);
50
+ }
51
+ this.node.appendChild(list);
52
+ }
53
+ _select(id, option) {
54
+ this._selected = id;
55
+ for (const node of this.node.querySelectorAll('.jp-OceanumShare-item')) {
56
+ const selected = node === option;
57
+ node.classList.toggle('jp-mod-selected', selected);
58
+ node.setAttribute('aria-selected', String(selected));
59
+ }
60
+ }
61
+ }
62
+ export const EVERYONE_WARNING = 'Anyone with the link can read this notebook, including people without an Oceanum ' +
63
+ 'account. This cannot be limited to your organisation. Its name and your email ' +
64
+ 'address also become visible to anyone who lists Oceanum notebooks.';
65
+ const PUBLIC_GRANT = {
66
+ type: 'public',
67
+ entity: '',
68
+ permission: 'read'
69
+ };
70
+ /** Dialog body for sharing; its value is the change to make, or `null` if incomplete. */
71
+ export class ShareBody extends Widget {
72
+ constructor(link) {
73
+ super();
74
+ this.addClass('jp-OceanumShare-body');
75
+ this.node.appendChild(element('h3', 'jp-OceanumShare-heading', 'Link'));
76
+ const row = element('div', 'jp-OceanumShare-linkRow');
77
+ const linkInput = element('input', 'jp-OceanumShare-link');
78
+ linkInput.readOnly = true;
79
+ linkInput.value = link;
80
+ linkInput.setAttribute('aria-label', 'Notebook link');
81
+ const copy = element('button', 'jp-mod-styled jp-OceanumShare-copy', 'Copy link');
82
+ copy.type = 'button';
83
+ copy.addEventListener('click', () => {
84
+ Clipboard.copyToSystem(link);
85
+ copy.textContent = 'Copied';
86
+ });
87
+ row.append(linkInput, copy);
88
+ this.node.appendChild(row);
89
+ this.node.appendChild(element('p', 'jp-OceanumShare-hint', 'The link opens the notebook for anyone it is shared with.'));
90
+ this.node.appendChild(element('h3', 'jp-OceanumShare-heading', 'Share with'));
91
+ const name = `jp-OceanumShare-who-${++ShareBody._count}`;
92
+ this._person = this._radio(name, 'A specific person', true);
93
+ const personRow = element('div', 'jp-OceanumShare-personRow');
94
+ this._email = element('textarea', 'jp-OceanumShare-email');
95
+ this._email.rows = 2;
96
+ this._email.placeholder = 'name@example.com, another@example.com';
97
+ this._email.setAttribute('aria-label', 'Email addresses');
98
+ this._level = element('select', 'jp-OceanumShare-level');
99
+ this._level.setAttribute('aria-label', 'Access');
100
+ for (const [value, label] of [
101
+ ['read', 'can view'],
102
+ ['write', 'can edit']
103
+ ]) {
104
+ const option = element('option', '', label);
105
+ option.value = value;
106
+ this._level.appendChild(option);
107
+ }
108
+ personRow.append(this._email, this._level);
109
+ this.node.appendChild(personRow);
110
+ this.node.appendChild(element('p', 'jp-OceanumShare-hint',
111
+ // Not "new lines": JupyterLab's dialog swallows Enter before it reaches the
112
+ // textarea, so one cannot be typed here. Pasted ones still separate addresses.
113
+ 'Separate several addresses with commas or spaces. Everyone you add gets the access chosen here.'));
114
+ this._everyone = this._radio(name, 'Everyone with the link', false);
115
+ this.node.appendChild(element('p', 'jp-OceanumShare-warning', EVERYONE_WARNING));
116
+ this._stopEveryone = this._radio(name, 'Stop sharing with everyone', false);
117
+ const update = () => {
118
+ this._email.disabled = !this._person.checked;
119
+ this._level.disabled = !this._person.checked;
120
+ };
121
+ for (const radio of [this._person, this._everyone, this._stopEveryone]) {
122
+ radio.addEventListener('change', update);
123
+ }
124
+ }
125
+ getValue() {
126
+ if (this._everyone.checked) {
127
+ return { action: 'grant', grants: [PUBLIC_GRANT], invalid: [] };
128
+ }
129
+ if (this._stopEveryone.checked) {
130
+ return { action: 'revoke', grant: PUBLIC_GRANT };
131
+ }
132
+ const { emails, invalid } = parseShareEmails(this._email.value);
133
+ if (emails.length === 0 && invalid.length === 0) {
134
+ return null;
135
+ }
136
+ const permission = this._level.value === 'write' ? 'write' : 'read';
137
+ return {
138
+ action: 'grant',
139
+ grants: emails.map(entity => ({ type: 'user', entity, permission })),
140
+ invalid
141
+ };
142
+ }
143
+ _radio(name, text, checked) {
144
+ const label = element('label', 'jp-OceanumShare-choice');
145
+ const input = element('input', 'jp-OceanumShare-radio');
146
+ input.type = 'radio';
147
+ input.name = name;
148
+ input.checked = checked;
149
+ label.append(input, document.createTextNode(` ${text}`));
150
+ this.node.appendChild(label);
151
+ return input;
152
+ }
153
+ }
154
+ ShareBody._count = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oceanum/oceanumlab",
3
- "version": "4.7.0",
3
+ "version": "4.7.2",
4
4
  "description": "A Jupyterlab extension to interact with the Oceanum.io platform",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -60,8 +60,20 @@
60
60
  "@jupyterlab/application": "^4.6.3",
61
61
  "@jupyterlab/apputils": "^4.7.3",
62
62
  "@jupyterlab/cells": "^4.6.3",
63
+ "@jupyterlab/console": "^4.6.3",
64
+ "@jupyterlab/coreutils": "^6.6.3",
65
+ "@jupyterlab/docmanager": "^4.6.3",
66
+ "@jupyterlab/mainmenu": "^4.6.3",
67
+ "@jupyterlab/nbformat": "^4.6.3",
63
68
  "@jupyterlab/notebook": "^4.6.3",
69
+ "@jupyterlab/services": "^7.6.3",
64
70
  "@jupyterlab/settingregistry": "^4.6.3",
71
+ "@lumino/commands": "^2.3.3",
72
+ "@lumino/coreutils": "^2.2.2",
73
+ "@lumino/disposable": "^2.1.5",
74
+ "@lumino/messaging": "^2.0.3",
75
+ "@lumino/signaling": "^2.1.5",
76
+ "@lumino/widgets": "^2.8.0",
65
77
  "@phosphor-icons/react": "^2.1.10",
66
78
  "marked": "^18.0.13"
67
79
  },
@@ -18,18 +18,6 @@
18
18
  "description": "Inject Datamesh token into code.",
19
19
  "default": false
20
20
  },
21
- "datameshUiUrl": {
22
- "type": "string",
23
- "title": "Datamesh UI URL",
24
- "description": "Address of the Datamesh UI shown in the Datamesh panel. Use the address it is finally served from: the panel only accepts workspace updates from this address's origin, so an address that redirects to another origin will not update the workspace.",
25
- "default": "https://ui.datamesh.oceanum.io"
26
- },
27
- "aiBackendUrl": {
28
- "type": "string",
29
- "title": "Oceanum AI URL",
30
- "description": "Address of the Oceanum AI backend used by the AI chat.",
31
- "default": "https://ai.oceanum.io"
32
- },
33
21
  "autoRunCode": {
34
22
  "type": "boolean",
35
23
  "title": "Auto-run code",
@@ -43,5 +31,8 @@
43
31
  "default": false
44
32
  }
45
33
  },
34
+ "patternProperties": {
35
+ "^(datameshUiUrl|aiBackendUrl)$": {}
36
+ },
46
37
  "additionalProperties": false
47
38
  }
@@ -3,20 +3,11 @@ import { ReactWidget } from '@jupyterlab/apputils';
3
3
  import { datameshUiSrc } from './datameshUiUrl';
4
4
 
5
5
  export class DatameshUI extends ReactWidget {
6
- /** `url` is the `datameshUiUrl` setting. */
6
+ /** `url` is the deployment's Datamesh UI address (the sign-in environment's). */
7
7
  constructor(private _url: string) {
8
8
  super();
9
9
  }
10
10
 
11
- /**
12
- * Point the panel at the `datameshUiUrl` setting's new value. The iframe
13
- * reloads only if its address actually changes.
14
- */
15
- set url(url: string) {
16
- this._url = url;
17
- this.update();
18
- }
19
-
20
11
  /** The iframe's window, for telling its messages apart; `null` if none. */
21
12
  get frame(): Window | null {
22
13
  return this.node.querySelector('iframe')?.contentWindow ?? null;
@@ -21,11 +21,16 @@ import React from 'react';
21
21
  import { marked } from 'marked';
22
22
 
23
23
  import { DatasourceItem } from './DatasourceItem';
24
+ import { IOceanumAuth } from './auth/tokens';
25
+ import { startSignIn } from './auth/startSignIn';
26
+ import { StoredNotebooks } from './StoredNotebooks';
27
+ import { ITab, Tabs } from './Tabs';
24
28
  import {
25
29
  SIGN_IN_COMMAND,
26
30
  aiBackendUrl,
27
31
  aiCredentialSource,
28
32
  canSignIn,
33
+ isSignedIn,
29
34
  pastedToken,
30
35
  resolveAiCredential,
31
36
  signInToken,
@@ -476,7 +481,7 @@ interface IAiSettings {
476
481
  * Not `window.datameshToken`, which keeps a token after it is cleared.
477
482
  */
478
483
  datameshToken: () => string;
479
- /** The `aiBackendUrl` setting. */
484
+ /** The deployment's Oceanum AI address. */
480
485
  aiBackendUrl: () => string;
481
486
  /** Emitted when the settings change. */
482
487
  settingsChanged: ISignal<unknown, void>;
@@ -493,7 +498,9 @@ interface IAiAccess {
493
498
  pasted: string;
494
499
  /** Whether the host can sign the user in to Oceanum.io on this site. */
495
500
  canSignIn: boolean;
496
- /** The backend address, from the `aiBackendUrl` setting. */
501
+ /** Whether the host says the user is signed in to Oceanum.io. */
502
+ signedIn: boolean;
503
+ /** The backend address: the deployment's Oceanum AI. */
497
504
  backend: string;
498
505
  }
499
506
 
@@ -506,6 +513,7 @@ function readAiAccess(
506
513
  source: aiCredentialSource(datameshToken, commands),
507
514
  pasted: pastedToken(datameshToken),
508
515
  canSignIn: canSignIn(commands),
516
+ signedIn: isSignedIn(commands),
509
517
  backend: aiBackendUrl(settings.aiBackendUrl())
510
518
  };
511
519
  }
@@ -583,8 +591,15 @@ function TokenConfigMessage({
583
591
 
584
592
  return (
585
593
  <div className="oceanum-token-config">
586
- Set your <a onClick={openSettings}>Datamesh token</a> to enable Oceanum.io
587
- services{' '}
594
+ Set your{' '}
595
+ <a
596
+ onClick={() =>
597
+ commands.execute('settingeditor:open', { query: 'Oceanum' })
598
+ }
599
+ >
600
+ Datamesh token
601
+ </a>{' '}
602
+ to enable Oceanum.io services{' '}
588
603
  <a
589
604
  href="https://home.oceanum.io/account"
590
605
  target="_blank"
@@ -935,18 +950,87 @@ export interface IDatameshWidgetProps {
935
950
  name: string;
936
951
  icon: LabIcon;
937
952
  openDatameshUI: any;
938
- /** The `datameshUiUrl` setting. */
953
+ /** The deployment's Datamesh UI address. */
939
954
  datameshUiUrl: () => string;
940
955
  /** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
941
956
  datameshUiFrame: () => Window | null;
942
957
  /** The `datameshToken` setting. */
943
958
  datameshToken: () => string;
944
- /** The `aiBackendUrl` setting. */
959
+ /** The deployment's Oceanum AI address. */
945
960
  aiBackendUrl: () => string;
946
961
  /** Emitted when the settings change. */
947
962
  settingsChanged: ISignal<unknown, void>;
948
963
  commands: CommandRegistry;
949
964
  getCurrentWidget: () => Widget;
965
+ /** Oceanum.io sign-in, or null on a host with no Oceanum environment. */
966
+ auth?: IOceanumAuth | null;
967
+ /** Open a stored notebook by spec store id. */
968
+ openStoredNotebook?: (id: string) => void;
969
+ }
970
+
971
+ /**
972
+ * The panel's tabs.
973
+ *
974
+ * A component rather than a list built in `render`, because whether Oceanum AI is
975
+ * reachable is not static: `useAiAccess` re-reads it when the settings change and when
976
+ * the host signals a sign-in or sign-out. An AI tab is only offered when the chat has a
977
+ * credential to work with — otherwise it would open on the same nothing the panel
978
+ * rendered before there were tabs, except now with a label promising otherwise.
979
+ */
980
+ function PanelTabs({
981
+ auth,
982
+ commands,
983
+ settings,
984
+ renderDatamesh,
985
+ openStoredNotebook,
986
+ selected,
987
+ onSelect
988
+ }: {
989
+ auth: IOceanumAuth | null;
990
+ commands: CommandRegistry;
991
+ settings: IAiSettings;
992
+ renderDatamesh: () => React.ReactElement;
993
+ openStoredNotebook?: (id: string) => void;
994
+ selected: string;
995
+ onSelect: (id: string) => void;
996
+ }): React.ReactElement {
997
+ const access = useAiAccess(commands, settings);
998
+ const tabs: ITab[] = [
999
+ {
1000
+ id: 'notebooks',
1001
+ label: 'Notebooks',
1002
+ render: () =>
1003
+ // An auth with no environment is a provider that has nothing to sign in to (the
1004
+ // server has sign-in turned off). Asking the user to sign in would be a dead end.
1005
+ auth?.environment ? (
1006
+ <StoredNotebooks
1007
+ auth={auth}
1008
+ onOpen={openStoredNotebook && (item => openStoredNotebook(item.id))}
1009
+ onSignIn={() => {
1010
+ startSignIn(commands, auth).catch(error => {
1011
+ console.warn('Oceanum.io sign-in could not start.', error);
1012
+ });
1013
+ }}
1014
+ />
1015
+ ) : (
1016
+ <div className="oceanum-text-empty">
1017
+ Oceanum.io sign-in is not configured for this host.
1018
+ </div>
1019
+ )
1020
+ },
1021
+ { id: 'datamesh', label: 'Datamesh', render: renderDatamesh }
1022
+ ];
1023
+ // Signed in, not merely holding a credential: a Datamesh token pasted into the
1024
+ // settings would reach the backend, but the tab stays out of the way until someone
1025
+ // has signed in to Oceanum.io.
1026
+ if (access.signedIn) {
1027
+ tabs.push({
1028
+ id: 'ai',
1029
+ label: 'Oceanum AI',
1030
+ render: () => <AIChatPanel commands={commands} settings={settings} />
1031
+ });
1032
+ }
1033
+ return <Tabs tabs={tabs} selected={selected} onSelect={onSelect} />;
950
1034
  }
951
1035
 
952
1036
  /**
@@ -1014,18 +1098,10 @@ export class DatameshConnectWidget extends ReactWidget {
1014
1098
  );
1015
1099
  }
1016
1100
 
1017
- render(): React.ReactElement {
1101
+ /** The Datamesh workspace tab: what this panel showed above the divider. */
1102
+ renderDatamesh(): React.ReactElement {
1018
1103
  return (
1019
- <div className="datamesh-connect">
1020
- <header className="oceanum-sidebar-header">
1021
- <this.props.icon.react
1022
- tag="span"
1023
- width="auto"
1024
- height="20px"
1025
- verticalAlign="middle"
1026
- />
1027
- <span className="oceanum-sidebar-title">Oceanum.io</span>
1028
- </header>
1104
+ <>
1029
1105
  <div className="datamesh-workspace-header">
1030
1106
  <span>Datamesh Workspace</span>
1031
1107
  <div
@@ -1047,9 +1123,57 @@ export class DatameshConnectWidget extends ReactWidget {
1047
1123
  settings={this.props}
1048
1124
  />
1049
1125
  </div>
1050
- <div className="datamesh-connect-divider" />
1051
- <AIChatPanel commands={this.props.commands} settings={this.props} />
1126
+ </>
1127
+ );
1128
+ }
1129
+
1130
+ render(): React.ReactElement {
1131
+ return (
1132
+ <div className="datamesh-connect">
1133
+ <header className="oceanum-sidebar-header">
1134
+ <this.props.icon.react
1135
+ tag="span"
1136
+ width="auto"
1137
+ height="20px"
1138
+ verticalAlign="middle"
1139
+ />
1140
+ <span className="oceanum-sidebar-title">Oceanum.io</span>
1141
+ </header>
1142
+ <UseSignal signal={this.tabChanged} initialArgs={this.selectedTab}>
1143
+ {(): React.ReactElement => (
1144
+ <PanelTabs
1145
+ auth={this.props.auth ?? null}
1146
+ commands={this.props.commands}
1147
+ settings={this.props}
1148
+ renderDatamesh={() => this.renderDatamesh()}
1149
+ openStoredNotebook={this.props.openStoredNotebook}
1150
+ selected={this.selectedTab}
1151
+ onSelect={id => this.selectTab(id)}
1152
+ />
1153
+ )}
1154
+ </UseSignal>
1052
1155
  </div>
1053
1156
  );
1054
1157
  }
1158
+
1159
+ /**
1160
+ * The visible tab. The widget only holds it and announces changes through
1161
+ * `tabChanged`; the plugin persists it in IStateDB, because the layout restorer
1162
+ * restores a widget's place in the shell rather than fields on it.
1163
+ */
1164
+ get selectedTab(): string {
1165
+ return this._selectedTab;
1166
+ }
1167
+
1168
+ selectTab(id: string): void {
1169
+ if (id !== this._selectedTab) {
1170
+ this._selectedTab = id;
1171
+ this.tabChanged.emit(id);
1172
+ }
1173
+ }
1174
+
1175
+ readonly tabChanged = new Signal<this, string>(this);
1176
+ // Notebooks first: on notebook.oceanum.io this panel is how a user reaches their
1177
+ // work, so it opens there rather than on Datamesh.
1178
+ private _selectedTab = 'notebooks';
1055
1179
  }