thebe-react
Version:
React providers and components for thebe-core
321 lines (297 loc) • 9.51 kB
text/typescript
import { createRef, useEffect, useState } from 'react';
import type { ThebeNotebook, ThebeSession, IThebeCell, IThebeCellExecuteReturn } from 'thebe-core';
import { useThebeConfig } from '../ThebeServerProvider';
import { useThebeLoader } from '../ThebeLoaderProvider';
import type { INotebookContent } from '@jupyterlab/nbformat';
import { useThebeSession } from '../ThebeSessionProvider';
import { useRenderMimeRegistry } from '../ThebeRenderMimeRegistryProvider';
export interface NotebookExecuteOptions {
stopOnError?: boolean;
before?: () => void;
after?: () => void;
preprocessor?: (s: string) => string;
}
export type IThebeNotebookError = IThebeCellExecuteReturn & { index: number };
export function findErrors(execReturns: (IThebeCellExecuteReturn | null)[]) {
return execReturns.reduce<IThebeNotebookError[] | null>(
(acc, retval: IThebeCellExecuteReturn | null, index) => {
if (retval?.error) {
if (acc == null) return [{ ...retval, index }];
else return [...acc, { ...retval, index }];
}
return acc;
},
null,
);
}
export function useNotebookBase() {
const { session, ready: sessionReady } = useThebeSession();
const [notebook, setNotebook] = useState<ThebeNotebook | undefined>();
// TODO move the refs to caller hooks as it does so little to maintain them in here.
const [refs, setRefs] = useState<((node: HTMLDivElement) => void)[]>([]);
const [sessionAttached, setSessionAttached] = useState(false);
const [executing, setExecuting] = useState<boolean>(false);
const [executed, setExecuted] = useState(false);
const [errors, setErrors] = useState<IThebeNotebookError[] | null>(null);
/**
* When the notebook and session is avaiable, attach to session
*/
useEffect(() => {
if (!notebook || !session || !sessionReady) return;
console.debug(`thebe-react: attaching notebook to session`, { notebook, session });
notebook.attachSession(session);
setSessionAttached(true);
}, [notebook, session, sessionReady]);
const executeAll = (options?: NotebookExecuteOptions) => {
if (!notebook) throw new Error('executeAll called before notebook available');
if (!session) throw new Error('executeAll called before session available');
options?.before?.();
setExecuting(true);
return notebook
.executeAll(options?.stopOnError ?? true, options?.preprocessor)
.then((execReturns) => {
options?.after?.();
const errs = findErrors(execReturns);
if (errs != null) setErrors(errs);
setExecuted(true);
setExecuting(false);
return execReturns;
});
};
const executeSome = (
predicate: (cell: IThebeCell) => boolean,
options?: NotebookExecuteOptions,
) => {
if (!notebook) throw new Error('executeSome called before notebook available');
if (!session) throw new Error('executeAll called before session available');
options?.before?.();
setExecuting(true);
const filteredCells = notebook.cells.filter(predicate).map((c) => c.id);
return notebook
.executeCells(filteredCells, options?.stopOnError ?? true, options?.preprocessor)
.then((execReturns) => {
options?.after?.();
const errs = findErrors(execReturns);
if (errs != null) setErrors(errs);
setExecuted(true);
setExecuting(false);
return execReturns;
});
};
const clear = () => {
if (!notebook) throw new Error('clear called before notebook available');
notebook.clear();
setExecuted(false);
};
return {
ready: !!notebook && sessionAttached,
attached: sessionAttached,
executing,
executed,
errors,
notebook,
setNotebook,
refs,
setRefs,
executeAll,
executeSome,
clear,
session,
};
}
/**
* @param name - provided to the fetcher function
* @param fetchNotebook - an async function, that given a name, can return a JSON representation of an ipynb file (INotebookContent)
* @param opts - options.refsForWidgetsOnly=false allows refs to be generated for all notebook cells, rather than onlythose with widget tags
* @returns
*/
export function useNotebook(
name: string,
fetchNotebook: (name: string) => Promise<INotebookContent>,
opts = { refsForWidgetsOnly: true },
) {
const { core } = useThebeLoader();
const { config } = useThebeConfig();
const rendermime = useRenderMimeRegistry();
const [loading, setLoading] = useState<boolean>(false);
if (!rendermime) throw new Error('ThebeSessionProvider requires a RenderMimeRegistryProvider');
const {
ready,
attached,
executing,
executed,
errors,
notebook,
setNotebook,
refs,
setRefs,
executeAll,
executeSome,
clear,
session,
} = useNotebookBase();
/**
* - set loading flag
* - load the notebook
* - setup callback refs, to auto-attach to dom
* - set notebook, which triggers
* - clear loading flag
*/
useEffect(() => {
if (!core || !config) return;
setLoading(true);
fetchNotebook(name)
.then((ipynb) => {
return core?.ThebeNotebook.fromIpynb(ipynb, config, rendermime);
})
.then((nb: ThebeNotebook) => {
const cells = opts?.refsForWidgetsOnly ? nb?.widgets ?? [] : nb?.cells ?? [];
// set up an array of callback refs to update the DOM elements
setRefs(
Array(cells.length)
.fill(null)
.map((_, idx) => (node) => {
console.debug(`new ref[${idx}] - attaching to dom...`, node);
if (node != null) cells[idx].attachToDOM(node);
}),
);
setNotebook(nb);
setLoading(false);
});
}, [core, config]);
return {
ready,
loading,
attached,
executing,
executed,
errors,
notebook,
cellRefs: refs,
cellIds: (opts.refsForWidgetsOnly ? notebook?.widgets ?? [] : notebook?.cells ?? []).map(
(c) => c.id,
),
executeAll,
executeSome,
clear,
session,
};
}
/**
* @param sourceCode - just an array of valid code blocks as single line strings
* @param opts - options.refsForWidgetsOnly=false allows refs to be generated for all notebook cells, rather than onlythose with widget tags
* @returns
*/
export function useNotebookFromSource(sourceCode: string[], opts = { refsForWidgetsOnly: true }) {
const { core } = useThebeLoader();
const { config } = useThebeConfig();
const rendermime = useRenderMimeRegistry();
const [loading, setLoading] = useState(false);
if (!rendermime) throw new Error('ThebeSessionProvider requires a RenderMimeRegistryProvider');
const {
ready,
attached,
executing,
executed,
errors,
notebook,
setNotebook,
refs,
setRefs,
executeAll,
executeSome,
clear,
session,
} = useNotebookBase();
useEffect(() => {
if (!core || !config || loading || notebook) return;
setLoading(true);
const nb = core.ThebeNotebook.fromCodeBlocks(
sourceCode.map((source) => ({ id: core?.shortId(), source })),
config,
rendermime,
);
const cells = opts?.refsForWidgetsOnly ? nb?.widgets ?? [] : nb?.cells ?? [];
setRefs(
Array(cells.length)
.fill(null)
.map((_, idx) => (node) => {
console.debug(`new ref[${idx}] - attaching to dom...`, node);
if (node != null) cells[idx].attachToDOM(node);
}),
);
setNotebook(nb);
setLoading(false);
}, [core, notebook, loading]);
return {
ready,
loading,
attached,
executing,
executed,
errors,
notebook,
cellRefs: refs,
cellIds: (opts.refsForWidgetsOnly ? notebook?.widgets ?? [] : notebook?.cells ?? []).map(
(c) => c.id,
),
executeAll,
executeSome,
clear,
session,
};
}
/**
* DEPRECATED - migrate to useNotebookFromSource
*/
export function useNotebookfromSourceLegacy(sourceCode: string[]) {
const { core } = useThebeLoader();
const { config } = useThebeConfig();
const rendermime = useRenderMimeRegistry();
if (!rendermime) throw new Error('ThebeSessionProvider requires a RenderMimeRegistryProvider');
const [busy, setBusy] = useState<boolean>(false);
const [notebook, setNotebook] = useState<ThebeNotebook | undefined>();
const [_, setReRender] = useState({});
const [cellRefs] = useState<React.RefObject<HTMLDivElement>[]>(
Array(sourceCode.length)
.fill(undefined)
.map(() => createRef()),
);
useEffect(() => {
if (!core || !config || notebook) return;
setNotebook(
core.ThebeNotebook.fromCodeBlocks(
sourceCode.map((source) => ({ id: core?.shortId(), source })),
config,
rendermime,
),
);
}, [core, notebook]);
const execute = () => {
if (!notebook) throw new Error('execute called before notebook available');
setBusy(true);
notebook.executeAll().then(() => {
setBusy(false);
});
};
const attach = (session: ThebeSession) => {
if (session.kernel == null) return;
if (!notebook) {
console.warn('attach called before notebook available');
return;
}
notebook?.detachSession();
notebook?.attachSession(session);
notebook?.cells.forEach((cell: IThebeCell, idx: number) => {
if (cellRefs[idx].current) cell.attachToDOM(cellRefs[idx].current ?? undefined);
});
};
return {
notebook,
busy,
execute,
attach,
cellRefs,
rerender: () => setReRender({}),
};
}