@oceanum/oceanumlab 4.6.2 → 4.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -11
- package/lib/DatameshUI.d.ts +1 -6
- package/lib/DatameshUI.js +1 -9
- package/lib/DatameshWidget.d.ts +24 -2
- package/lib/DatameshWidget.js +171 -45
- package/lib/StoredNotebooks.d.ts +16 -0
- package/lib/StoredNotebooks.js +84 -0
- package/lib/Tabs.d.ts +26 -0
- package/lib/Tabs.js +45 -0
- package/lib/aiBackend.d.ts +66 -0
- package/lib/aiBackend.js +101 -0
- package/lib/auth/accountWidget.d.ts +35 -0
- package/lib/auth/accountWidget.js +95 -0
- package/lib/auth/claims.d.ts +3 -0
- package/lib/auth/claims.js +25 -0
- package/lib/auth/plugin.d.ts +9 -0
- package/lib/auth/plugin.js +126 -0
- package/lib/auth/serverAuth.d.ts +79 -0
- package/lib/auth/serverAuth.js +253 -0
- package/lib/auth/tokens.d.ts +95 -0
- package/lib/auth/tokens.js +9 -0
- package/lib/chatRouter.d.ts +17 -1
- package/lib/chatRouter.js +34 -9
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +2 -2
- package/lib/datameshUiUrl.js +8 -16
- package/lib/httpUrl.d.ts +9 -0
- package/lib/httpUrl.js +20 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.js +67 -15
- package/lib/specStore.d.ts +37 -0
- package/lib/specStore.js +57 -0
- package/package.json +9 -1
- package/schema/datamesh-connect.json +3 -6
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +294 -59
- package/src/StoredNotebooks.tsx +166 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +393 -0
- package/src/__tests__/OceanumPanel.spec.tsx +101 -0
- package/src/__tests__/Tabs.spec.tsx +168 -0
- package/src/__tests__/aiBackend.spec.ts +271 -0
- package/src/__tests__/chatRouter.spec.ts +253 -17
- package/src/__tests__/datameshUiUrl.spec.ts +4 -3
- package/src/__tests__/pluginArity.spec.ts +24 -0
- package/src/__tests__/serverAuth.spec.ts +277 -0
- package/src/__tests__/settingsSchema.spec.ts +73 -0
- package/src/__tests__/specStore.spec.ts +126 -0
- package/src/aiBackend.ts +150 -0
- package/src/auth/accountWidget.ts +113 -0
- package/src/auth/claims.ts +30 -0
- package/src/auth/plugin.ts +155 -0
- package/src/auth/serverAuth.ts +341 -0
- package/src/auth/tokens.ts +104 -0
- package/src/chatRouter.ts +41 -9
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +8 -15
- package/src/httpUrl.ts +23 -0
- package/src/index.ts +99 -33
- package/src/specStore.ts +82 -0
- package/style/index.css +158 -0
package/src/DatameshWidget.tsx
CHANGED
|
@@ -14,14 +14,26 @@ import { LabIcon, addIcon } from '@jupyterlab/ui-components';
|
|
|
14
14
|
import { CommandRegistry } from '@lumino/commands';
|
|
15
15
|
import { MimeData, ReadonlyJSONArray } from '@lumino/coreutils';
|
|
16
16
|
import { Drag } from '@lumino/dragdrop';
|
|
17
|
-
import { Signal } from '@lumino/signaling';
|
|
17
|
+
import { ISignal, Signal } from '@lumino/signaling';
|
|
18
18
|
import { Widget } from '@lumino/widgets';
|
|
19
19
|
|
|
20
20
|
import React from 'react';
|
|
21
21
|
import { marked } from 'marked';
|
|
22
22
|
|
|
23
23
|
import { DatasourceItem } from './DatasourceItem';
|
|
24
|
-
import {
|
|
24
|
+
import { IOceanumAuth } from './auth/tokens';
|
|
25
|
+
import { StoredNotebooks } from './StoredNotebooks';
|
|
26
|
+
import { ITab, Tabs } from './Tabs';
|
|
27
|
+
import {
|
|
28
|
+
SIGN_IN_COMMAND,
|
|
29
|
+
aiBackendUrl,
|
|
30
|
+
aiCredentialSource,
|
|
31
|
+
canSignIn,
|
|
32
|
+
pastedToken,
|
|
33
|
+
resolveAiCredential,
|
|
34
|
+
signInToken,
|
|
35
|
+
type AiCredentialSource
|
|
36
|
+
} from './aiBackend';
|
|
25
37
|
import { isDatameshUiMessage } from './datameshUiUrl';
|
|
26
38
|
import { describeProgress, onProgress, type Progress } from './progress';
|
|
27
39
|
|
|
@@ -460,27 +472,118 @@ class DatameshWorkspaceDisplay extends React.Component<IDatameshWorkspaceProps>
|
|
|
460
472
|
}
|
|
461
473
|
}
|
|
462
474
|
|
|
475
|
+
/** The settings the sidebar reaches Oceanum AI with. */
|
|
476
|
+
interface IAiSettings {
|
|
477
|
+
/**
|
|
478
|
+
* The `datameshToken` setting: the same value a chat request is signed with.
|
|
479
|
+
* Not `window.datameshToken`, which keeps a token after it is cleared.
|
|
480
|
+
*/
|
|
481
|
+
datameshToken: () => string;
|
|
482
|
+
/** The deployment's Oceanum AI address. */
|
|
483
|
+
aiBackendUrl: () => string;
|
|
484
|
+
/** Emitted when the settings change. */
|
|
485
|
+
settingsChanged: ISignal<unknown, void>;
|
|
486
|
+
}
|
|
487
|
+
|
|
463
488
|
/**
|
|
464
|
-
*
|
|
465
|
-
*
|
|
489
|
+
* How the sidebar can reach Oceanum AI right now. Holds no sign-in token:
|
|
490
|
+
* finding one out means running the host's command.
|
|
491
|
+
*/
|
|
492
|
+
interface IAiAccess {
|
|
493
|
+
/** Which credential a request would carry; null when there is none. */
|
|
494
|
+
source: AiCredentialSource | null;
|
|
495
|
+
/** The pasted Datamesh token, trimmed; '' when there is none. */
|
|
496
|
+
pasted: string;
|
|
497
|
+
/** Whether the host can sign the user in to Oceanum.io on this site. */
|
|
498
|
+
canSignIn: boolean;
|
|
499
|
+
/** The backend address: the deployment's Oceanum AI. */
|
|
500
|
+
backend: string;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function readAiAccess(
|
|
504
|
+
commands: CommandRegistry,
|
|
505
|
+
settings: IAiSettings
|
|
506
|
+
): IAiAccess {
|
|
507
|
+
const datameshToken = settings.datameshToken();
|
|
508
|
+
return {
|
|
509
|
+
source: aiCredentialSource(datameshToken, commands),
|
|
510
|
+
pasted: pastedToken(datameshToken),
|
|
511
|
+
canSignIn: canSignIn(commands),
|
|
512
|
+
backend: aiBackendUrl(settings.aiBackendUrl())
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* How the sidebar can reach Oceanum AI, read again whenever the settings or the
|
|
518
|
+
* commands change: the host calls `notifyCommandChanged` when the user signs
|
|
519
|
+
* in or out.
|
|
520
|
+
*
|
|
521
|
+
* Only the registry's queries are used, never `execute`, and nothing runs on a
|
|
522
|
+
* timer: every executed command closes JupyterLab's command palette.
|
|
523
|
+
*/
|
|
524
|
+
function useAiAccess(
|
|
525
|
+
commands: CommandRegistry,
|
|
526
|
+
settings: IAiSettings
|
|
527
|
+
): IAiAccess {
|
|
528
|
+
const [access, setAccess] = React.useState(() =>
|
|
529
|
+
readAiAccess(commands, settings)
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
React.useEffect(() => {
|
|
533
|
+
const update = () => {
|
|
534
|
+
const next = readAiAccess(commands, settings);
|
|
535
|
+
// Same content keeps the same object, so nothing downstream re-runs.
|
|
536
|
+
setAccess(prev =>
|
|
537
|
+
JSON.stringify(prev) === JSON.stringify(next) ? prev : next
|
|
538
|
+
);
|
|
539
|
+
};
|
|
540
|
+
// Anything that changed between the first render and now.
|
|
541
|
+
update();
|
|
542
|
+
commands.commandChanged.connect(update);
|
|
543
|
+
settings.settingsChanged.connect(update);
|
|
544
|
+
return () => {
|
|
545
|
+
commands.commandChanged.disconnect(update);
|
|
546
|
+
settings.settingsChanged.disconnect(update);
|
|
547
|
+
};
|
|
548
|
+
}, [commands, settings]);
|
|
549
|
+
|
|
550
|
+
return access;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Shows a message prompting the user to sign in to Oceanum.io, or to configure
|
|
555
|
+
* their Datamesh token where the site has no sign-in. Only renders when there
|
|
556
|
+
* is no credential.
|
|
466
557
|
*/
|
|
467
558
|
function TokenConfigMessage({
|
|
468
|
-
commands
|
|
559
|
+
commands,
|
|
560
|
+
settings
|
|
469
561
|
}: {
|
|
470
562
|
commands: CommandRegistry;
|
|
563
|
+
settings: IAiSettings;
|
|
471
564
|
}): React.ReactElement | null {
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
React.useEffect(() => {
|
|
475
|
-
const checkToken = () => setHasToken(!!window.datameshToken);
|
|
476
|
-
const interval = setInterval(checkToken, 1000);
|
|
477
|
-
return () => clearInterval(interval);
|
|
478
|
-
}, []);
|
|
565
|
+
const access = useAiAccess(commands, settings);
|
|
479
566
|
|
|
480
|
-
if (
|
|
567
|
+
if (access.source) {
|
|
481
568
|
return null;
|
|
482
569
|
}
|
|
483
570
|
|
|
571
|
+
const openSettings = () =>
|
|
572
|
+
commands.execute('settingeditor:open', { query: 'Oceanum' });
|
|
573
|
+
|
|
574
|
+
if (access.canSignIn) {
|
|
575
|
+
const signIn = () =>
|
|
576
|
+
commands.execute(SIGN_IN_COMMAND).catch(() => {
|
|
577
|
+
console.warn('Oceanum AI: could not start signing in to Oceanum.io.');
|
|
578
|
+
});
|
|
579
|
+
return (
|
|
580
|
+
<div className="oceanum-token-config">
|
|
581
|
+
<a onClick={() => void signIn()}>Sign in to Oceanum.io</a> to use
|
|
582
|
+
Oceanum AI, or set a <a onClick={openSettings}>Datamesh token</a>.
|
|
583
|
+
</div>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
484
587
|
return (
|
|
485
588
|
<div className="oceanum-token-config">
|
|
486
589
|
Set your{' '}
|
|
@@ -491,7 +594,7 @@ function TokenConfigMessage({
|
|
|
491
594
|
>
|
|
492
595
|
Datamesh token
|
|
493
596
|
</a>{' '}
|
|
494
|
-
to enable Oceanum.
|
|
597
|
+
to enable Oceanum.io services{' '}
|
|
495
598
|
<a
|
|
496
599
|
href="https://home.oceanum.io/account"
|
|
497
600
|
target="_blank"
|
|
@@ -510,10 +613,12 @@ interface IChatMessage {
|
|
|
510
613
|
|
|
511
614
|
interface IAIChatPanelProps {
|
|
512
615
|
commands: CommandRegistry;
|
|
616
|
+
settings: IAiSettings;
|
|
513
617
|
}
|
|
514
618
|
|
|
515
619
|
function AIChatPanel({
|
|
516
|
-
commands
|
|
620
|
+
commands,
|
|
621
|
+
settings
|
|
517
622
|
}: IAIChatPanelProps): React.ReactElement | null {
|
|
518
623
|
const [messages, setMessages] = React.useState<IChatMessage[]>([]);
|
|
519
624
|
const [input, setInput] = React.useState('');
|
|
@@ -532,55 +637,81 @@ function AIChatPanel({
|
|
|
532
637
|
const [historyIndex, setHistoryIndex] = React.useState(-1);
|
|
533
638
|
const [tempInput, setTempInput] = React.useState('');
|
|
534
639
|
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
const
|
|
640
|
+
// Which credential and address the chat has, as the settings and the host
|
|
641
|
+
// report them.
|
|
642
|
+
const access = useAiAccess(commands, settings);
|
|
643
|
+
// What the capabilities depend on: which credential, the pasted token and
|
|
644
|
+
// the address. Not the sign-in token itself: it refreshes, and finding it out
|
|
645
|
+
// runs the host's command.
|
|
646
|
+
const capabilitiesKey = access.source
|
|
647
|
+
? JSON.stringify([access.source, access.pasted, access.backend])
|
|
648
|
+
: '';
|
|
538
649
|
|
|
539
650
|
// Check if code capability is enabled (null = loading/unknown)
|
|
540
651
|
const [codeEnabled, setCodeEnabled] = React.useState<boolean | null>(null);
|
|
541
652
|
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const currentToken = window.datameshToken || '';
|
|
546
|
-
if (currentToken !== token) {
|
|
547
|
-
setToken(currentToken);
|
|
548
|
-
}
|
|
549
|
-
};
|
|
550
|
-
const interval = setInterval(checkToken, 1000);
|
|
551
|
-
return () => clearInterval(interval);
|
|
552
|
-
}, [token]);
|
|
653
|
+
// The sidebar's one pending access-token request, shared rather than
|
|
654
|
+
// repeated if the capabilities are asked for again before it answers.
|
|
655
|
+
const tokenRequest = React.useRef<Promise<unknown> | null>(null);
|
|
553
656
|
|
|
554
|
-
// Fetch capabilities when
|
|
657
|
+
// Fetch capabilities when the credential or the address changes
|
|
555
658
|
React.useEffect(() => {
|
|
556
|
-
if (!
|
|
659
|
+
if (!access.source) {
|
|
557
660
|
setCodeEnabled(null);
|
|
558
661
|
return;
|
|
559
662
|
}
|
|
560
663
|
|
|
664
|
+
// An answer for a credential that has since changed is dropped.
|
|
665
|
+
let current = true;
|
|
666
|
+
const signInTokenOnce = (): Promise<unknown> => {
|
|
667
|
+
if (!tokenRequest.current) {
|
|
668
|
+
tokenRequest.current = signInToken(commands)().finally(() => {
|
|
669
|
+
tokenRequest.current = null;
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
return tokenRequest.current;
|
|
673
|
+
};
|
|
561
674
|
const fetchCapabilities = async () => {
|
|
675
|
+
const credential = await resolveAiCredential(
|
|
676
|
+
access.pasted,
|
|
677
|
+
signInTokenOnce
|
|
678
|
+
);
|
|
679
|
+
if (!current) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (!credential) {
|
|
683
|
+
// The host says signed in but has no token to give: leave it to a
|
|
684
|
+
// request to report, rather than hide the chat.
|
|
685
|
+
setCodeEnabled(null);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
562
688
|
try {
|
|
563
|
-
const response = await fetch(
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
570
|
-
);
|
|
689
|
+
const response = await fetch(`${access.backend}/api/capabilities`, {
|
|
690
|
+
headers: credential.headers
|
|
691
|
+
});
|
|
692
|
+
if (!current) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
571
695
|
if (response.ok) {
|
|
572
696
|
const data = await response.json();
|
|
573
|
-
|
|
697
|
+
if (current) {
|
|
698
|
+
setCodeEnabled(data.code === true);
|
|
699
|
+
}
|
|
574
700
|
} else {
|
|
575
701
|
setCodeEnabled(false);
|
|
576
702
|
}
|
|
577
703
|
} catch {
|
|
578
|
-
|
|
704
|
+
if (current) {
|
|
705
|
+
setCodeEnabled(false);
|
|
706
|
+
}
|
|
579
707
|
}
|
|
580
708
|
};
|
|
581
709
|
|
|
582
710
|
void fetchCapabilities();
|
|
583
|
-
|
|
711
|
+
return () => {
|
|
712
|
+
current = false;
|
|
713
|
+
};
|
|
714
|
+
}, [capabilitiesKey]);
|
|
584
715
|
|
|
585
716
|
// Auto-scroll to bottom when messages change
|
|
586
717
|
React.useEffect(() => {
|
|
@@ -722,8 +853,8 @@ function AIChatPanel({
|
|
|
722
853
|
}
|
|
723
854
|
};
|
|
724
855
|
|
|
725
|
-
// Hide AI panel if no
|
|
726
|
-
if (!
|
|
856
|
+
// Hide AI panel if no credential or code capability is not enabled
|
|
857
|
+
if (!access.source || codeEnabled === false) {
|
|
727
858
|
return null;
|
|
728
859
|
}
|
|
729
860
|
|
|
@@ -814,12 +945,74 @@ export interface IDatameshWidgetProps {
|
|
|
814
945
|
name: string;
|
|
815
946
|
icon: LabIcon;
|
|
816
947
|
openDatameshUI: any;
|
|
817
|
-
/** The
|
|
948
|
+
/** The deployment's Datamesh UI address. */
|
|
818
949
|
datameshUiUrl: () => string;
|
|
819
950
|
/** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
|
|
820
951
|
datameshUiFrame: () => Window | null;
|
|
952
|
+
/** The `datameshToken` setting. */
|
|
953
|
+
datameshToken: () => string;
|
|
954
|
+
/** The deployment's Oceanum AI address. */
|
|
955
|
+
aiBackendUrl: () => string;
|
|
956
|
+
/** Emitted when the settings change. */
|
|
957
|
+
settingsChanged: ISignal<unknown, void>;
|
|
821
958
|
commands: CommandRegistry;
|
|
822
959
|
getCurrentWidget: () => Widget;
|
|
960
|
+
/** Oceanum.io sign-in, or null on a host with no Oceanum environment. */
|
|
961
|
+
auth?: IOceanumAuth | null;
|
|
962
|
+
/** Open a stored notebook by spec store id. */
|
|
963
|
+
openStoredNotebook?: (id: string) => void;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* The panel's tabs.
|
|
968
|
+
*
|
|
969
|
+
* A component rather than a list built in `render`, because whether Oceanum AI is
|
|
970
|
+
* reachable is not static: `useAiAccess` re-reads it when the settings change and when
|
|
971
|
+
* the host signals a sign-in or sign-out. An AI tab is only offered when the chat has a
|
|
972
|
+
* credential to work with — otherwise it would open on the same nothing the panel
|
|
973
|
+
* rendered before there were tabs, except now with a label promising otherwise.
|
|
974
|
+
*/
|
|
975
|
+
function PanelTabs({
|
|
976
|
+
auth,
|
|
977
|
+
commands,
|
|
978
|
+
settings,
|
|
979
|
+
renderDatamesh,
|
|
980
|
+
selected,
|
|
981
|
+
onSelect
|
|
982
|
+
}: {
|
|
983
|
+
auth: IOceanumAuth | null;
|
|
984
|
+
commands: CommandRegistry;
|
|
985
|
+
settings: IAiSettings;
|
|
986
|
+
renderDatamesh: () => React.ReactElement;
|
|
987
|
+
selected: string;
|
|
988
|
+
onSelect: (id: string) => void;
|
|
989
|
+
}): React.ReactElement {
|
|
990
|
+
const access = useAiAccess(commands, settings);
|
|
991
|
+
const tabs: ITab[] = [
|
|
992
|
+
{
|
|
993
|
+
id: 'notebooks',
|
|
994
|
+
label: 'Notebooks',
|
|
995
|
+
render: () =>
|
|
996
|
+
// An auth with no environment is a provider that has nothing to sign in to (the
|
|
997
|
+
// server has sign-in turned off). Asking the user to sign in would be a dead end.
|
|
998
|
+
auth?.environment ? (
|
|
999
|
+
<StoredNotebooks auth={auth} />
|
|
1000
|
+
) : (
|
|
1001
|
+
<div className="oceanum-text-empty">
|
|
1002
|
+
Oceanum.io sign-in is not configured for this host.
|
|
1003
|
+
</div>
|
|
1004
|
+
)
|
|
1005
|
+
},
|
|
1006
|
+
{ id: 'datamesh', label: 'Datamesh', render: renderDatamesh }
|
|
1007
|
+
];
|
|
1008
|
+
if (access.source) {
|
|
1009
|
+
tabs.push({
|
|
1010
|
+
id: 'ai',
|
|
1011
|
+
label: 'Oceanum AI',
|
|
1012
|
+
render: () => <AIChatPanel commands={commands} settings={settings} />
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
return <Tabs tabs={tabs} selected={selected} onSelect={onSelect} />;
|
|
823
1016
|
}
|
|
824
1017
|
|
|
825
1018
|
/**
|
|
@@ -887,18 +1080,10 @@ export class DatameshConnectWidget extends ReactWidget {
|
|
|
887
1080
|
);
|
|
888
1081
|
}
|
|
889
1082
|
|
|
890
|
-
|
|
1083
|
+
/** The Datamesh workspace tab: what this panel showed above the divider. */
|
|
1084
|
+
renderDatamesh(): React.ReactElement {
|
|
891
1085
|
return (
|
|
892
|
-
|
|
893
|
-
<header className="oceanum-sidebar-header">
|
|
894
|
-
<this.props.icon.react
|
|
895
|
-
tag="span"
|
|
896
|
-
width="auto"
|
|
897
|
-
height="20px"
|
|
898
|
-
verticalAlign="middle"
|
|
899
|
-
/>
|
|
900
|
-
<span className="oceanum-sidebar-title">Oceanum.io</span>
|
|
901
|
-
</header>
|
|
1086
|
+
<>
|
|
902
1087
|
<div className="datamesh-workspace-header">
|
|
903
1088
|
<span>Datamesh Workspace</span>
|
|
904
1089
|
<div
|
|
@@ -915,11 +1100,61 @@ export class DatameshConnectWidget extends ReactWidget {
|
|
|
915
1100
|
this.renderDisplay(datameshWorkspace)
|
|
916
1101
|
}
|
|
917
1102
|
</UseSignal>
|
|
918
|
-
<TokenConfigMessage
|
|
1103
|
+
<TokenConfigMessage
|
|
1104
|
+
commands={this.props.commands}
|
|
1105
|
+
settings={this.props}
|
|
1106
|
+
/>
|
|
919
1107
|
</div>
|
|
920
|
-
|
|
921
|
-
|
|
1108
|
+
</>
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
render(): React.ReactElement {
|
|
1113
|
+
return (
|
|
1114
|
+
<div className="datamesh-connect">
|
|
1115
|
+
<header className="oceanum-sidebar-header">
|
|
1116
|
+
<this.props.icon.react
|
|
1117
|
+
tag="span"
|
|
1118
|
+
width="auto"
|
|
1119
|
+
height="20px"
|
|
1120
|
+
verticalAlign="middle"
|
|
1121
|
+
/>
|
|
1122
|
+
<span className="oceanum-sidebar-title">Oceanum.io</span>
|
|
1123
|
+
</header>
|
|
1124
|
+
<UseSignal signal={this.tabChanged} initialArgs={this.selectedTab}>
|
|
1125
|
+
{(): React.ReactElement => (
|
|
1126
|
+
<PanelTabs
|
|
1127
|
+
auth={this.props.auth ?? null}
|
|
1128
|
+
commands={this.props.commands}
|
|
1129
|
+
settings={this.props}
|
|
1130
|
+
renderDatamesh={() => this.renderDatamesh()}
|
|
1131
|
+
selected={this.selectedTab}
|
|
1132
|
+
onSelect={id => this.selectTab(id)}
|
|
1133
|
+
/>
|
|
1134
|
+
)}
|
|
1135
|
+
</UseSignal>
|
|
922
1136
|
</div>
|
|
923
1137
|
);
|
|
924
1138
|
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* The visible tab. The widget only holds it and announces changes through
|
|
1142
|
+
* `tabChanged`; the plugin persists it in IStateDB, because the layout restorer
|
|
1143
|
+
* restores a widget's place in the shell rather than fields on it.
|
|
1144
|
+
*/
|
|
1145
|
+
get selectedTab(): string {
|
|
1146
|
+
return this._selectedTab;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
selectTab(id: string): void {
|
|
1150
|
+
if (id !== this._selectedTab) {
|
|
1151
|
+
this._selectedTab = id;
|
|
1152
|
+
this.tabChanged.emit(id);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
readonly tabChanged = new Signal<this, string>(this);
|
|
1157
|
+
// Notebooks first: on notebook.oceanum.io this panel is how a user reaches their
|
|
1158
|
+
// work, so it opens there rather than on Datamesh.
|
|
1159
|
+
private _selectedTab = 'notebooks';
|
|
925
1160
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { IOceanumAuth } from './auth/tokens';
|
|
4
|
+
import { ISpecSummary, listNotebooks, partitionNotebooks } from './specStore';
|
|
5
|
+
|
|
6
|
+
export interface IStoredNotebooksProps {
|
|
7
|
+
auth: IOceanumAuth;
|
|
8
|
+
/**
|
|
9
|
+
* Open a stored notebook, when this host can. Undefined where nothing has registered
|
|
10
|
+
* a way to open one — today that is native JupyterLab, which has no spec store drive
|
|
11
|
+
* until OCE-182. The list then reads rather than offering an action that does nothing.
|
|
12
|
+
*/
|
|
13
|
+
onOpen?: (item: ISpecSummary) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Load =
|
|
17
|
+
| { state: 'loading' }
|
|
18
|
+
| { state: 'error'; message: string }
|
|
19
|
+
| { state: 'ready'; items: ISpecSummary[] };
|
|
20
|
+
|
|
21
|
+
function Section({
|
|
22
|
+
title,
|
|
23
|
+
items,
|
|
24
|
+
empty,
|
|
25
|
+
onOpen
|
|
26
|
+
}: {
|
|
27
|
+
title: string;
|
|
28
|
+
items: readonly ISpecSummary[];
|
|
29
|
+
empty: string;
|
|
30
|
+
onOpen?: (item: ISpecSummary) => void;
|
|
31
|
+
}): React.ReactElement {
|
|
32
|
+
return (
|
|
33
|
+
<div className="oceanum-notebooks-section">
|
|
34
|
+
<div className="oceanum-notebooks-heading">
|
|
35
|
+
{title}
|
|
36
|
+
<span className="oceanum-notebooks-count">{items.length}</span>
|
|
37
|
+
</div>
|
|
38
|
+
{items.length === 0 ? (
|
|
39
|
+
<div className="oceanum-text-empty">{empty}</div>
|
|
40
|
+
) : (
|
|
41
|
+
<ul className="oceanum-notebooks-list">
|
|
42
|
+
{items.map(item => (
|
|
43
|
+
<li key={item.id}>
|
|
44
|
+
{/* Names come from other users: React escapes these, never set as HTML. */}
|
|
45
|
+
{onOpen ? (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className="oceanum-notebooks-item"
|
|
49
|
+
onClick={() => onOpen(item)}
|
|
50
|
+
title={item.description ?? undefined}
|
|
51
|
+
>
|
|
52
|
+
<span className="oceanum-notebooks-name">
|
|
53
|
+
{item.name || 'Untitled'}
|
|
54
|
+
</span>
|
|
55
|
+
</button>
|
|
56
|
+
) : (
|
|
57
|
+
<div
|
|
58
|
+
className="oceanum-notebooks-item oceanum-notebooks-item-static"
|
|
59
|
+
title={item.description ?? undefined}
|
|
60
|
+
>
|
|
61
|
+
<span className="oceanum-notebooks-name">
|
|
62
|
+
{item.name || 'Untitled'}
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
|
|
76
|
+
*/
|
|
77
|
+
export function StoredNotebooks({
|
|
78
|
+
auth,
|
|
79
|
+
onOpen
|
|
80
|
+
}: IStoredNotebooksProps): React.ReactElement {
|
|
81
|
+
const [load, setLoad] = React.useState<Load>({ state: 'loading' });
|
|
82
|
+
const [user, setUser] = React.useState(auth.user);
|
|
83
|
+
|
|
84
|
+
// Reload whenever the signed-in user changes, so signing in or out does not leave a
|
|
85
|
+
// stale list from the previous session on screen.
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
const onUser = (): void => setUser(auth.user);
|
|
88
|
+
auth.userChanged.connect(onUser);
|
|
89
|
+
return () => {
|
|
90
|
+
auth.userChanged.disconnect(onUser);
|
|
91
|
+
};
|
|
92
|
+
}, [auth]);
|
|
93
|
+
|
|
94
|
+
React.useEffect(() => {
|
|
95
|
+
let cancelled = false;
|
|
96
|
+
const specs = auth.urls?.specs ?? null;
|
|
97
|
+
if (!user || !specs) {
|
|
98
|
+
setLoad({ state: 'ready', items: [] });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
setLoad({ state: 'loading' });
|
|
102
|
+
void (async () => {
|
|
103
|
+
try {
|
|
104
|
+
const token = await auth.getAccessToken();
|
|
105
|
+
if (cancelled) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!token) {
|
|
109
|
+
setLoad({ state: 'ready', items: [] });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const items = await listNotebooks(specs, token);
|
|
113
|
+
if (!cancelled) {
|
|
114
|
+
setLoad({ state: 'ready', items });
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (!cancelled) {
|
|
118
|
+
setLoad({
|
|
119
|
+
state: 'error',
|
|
120
|
+
message:
|
|
121
|
+
error instanceof Error
|
|
122
|
+
? error.message
|
|
123
|
+
: 'Could not reach Oceanum.io.'
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
return () => {
|
|
129
|
+
cancelled = true;
|
|
130
|
+
};
|
|
131
|
+
}, [auth, user]);
|
|
132
|
+
|
|
133
|
+
if (!user) {
|
|
134
|
+
return (
|
|
135
|
+
<div className="oceanum-text-empty">
|
|
136
|
+
Sign in to Oceanum.io to see your notebooks.
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (load.state === 'loading') {
|
|
141
|
+
return <div className="oceanum-text-empty">Loading…</div>;
|
|
142
|
+
}
|
|
143
|
+
if (load.state === 'error') {
|
|
144
|
+
// Never render the sections alongside an error: their empty messages would claim
|
|
145
|
+
// the account has no notebooks when the load simply failed.
|
|
146
|
+
return <div className="oceanum-text-error">{load.message}</div>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { mine, shared } = partitionNotebooks(load.items, user.email);
|
|
150
|
+
return (
|
|
151
|
+
<div className="oceanum-notebooks">
|
|
152
|
+
<Section
|
|
153
|
+
title="My notebooks"
|
|
154
|
+
items={mine}
|
|
155
|
+
empty="You have not saved any notebooks yet."
|
|
156
|
+
onOpen={onOpen}
|
|
157
|
+
/>
|
|
158
|
+
<Section
|
|
159
|
+
title="Shared with me"
|
|
160
|
+
items={shared}
|
|
161
|
+
empty="No notebooks have been shared with you."
|
|
162
|
+
onOpen={onOpen}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|