From 26dcc294c2cbb81068e6fe6a97d4529864a34f68 Mon Sep 17 00:00:00 2001 From: Duc Trung Le Date: Wed, 19 Aug 2026 10:45:41 +0200 Subject: [PATCH 1/6] refactor --- src/specta_model.ts | 9 +- src/specta_widget.ts | 13 +- src/specta_widget_factory.ts | 4 +- src/token.ts | 165 ++++++++++++++++++++++++++ src/topbar/menuComponent.tsx | 10 +- src/topbar/settingDialog.tsx | 4 +- src/topbar/staticRenderingSection.tsx | 7 +- 7 files changed, 191 insertions(+), 21 deletions(-) diff --git a/src/specta_model.ts b/src/specta_model.ts index 9cfe79e..2671582 100644 --- a/src/specta_model.ts +++ b/src/specta_model.ts @@ -41,8 +41,9 @@ import { import { ISignal, Signal } from '@lumino/signaling'; import { INotebookContent } from '@jupyterlab/nbformat'; +import { IAppModel, ISpectaSnapshotStatus } from './token'; -export class AppModel { +export class AppModel implements IAppModel { constructor(private options: AppModel.IOptions) { this._staticRender = Boolean(this.getSnapshot()); this._filePath = options.context.localPath; @@ -75,7 +76,7 @@ export class AppModel { return this._fileChanged; } - get staticRender() { + get staticRender(): boolean { return this._staticRender; } get snapshotChanged(): ISignal { @@ -275,7 +276,7 @@ export class AppModel { return item; } - async turnOffStaticRender() { + async turnOffStaticRender(): Promise { if (!this._staticRender) { return; } @@ -348,7 +349,7 @@ export class AppModel { ); } - snapshotStatus(): 'out-of-sync' | 'in-sync' | 'not-exist' { + snapshotStatus(): ISpectaSnapshotStatus { const sn = this.getSnapshot(); if (!sn) { return 'not-exist'; diff --git a/src/specta_widget.ts b/src/specta_widget.ts index 61b709e..8bb3e3f 100644 --- a/src/specta_widget.ts +++ b/src/specta_widget.ts @@ -4,8 +4,9 @@ import { Message } from '@lumino/messaging'; import { Panel, Widget } from '@lumino/widgets'; import { SpectaCellOutput } from './specta_cell_output'; -import type { AppModel } from './specta_model'; import { + IAppModel, + IAppWidget, ISpectaAppConfig, ISpectaLayout, ISpectaLayoutRegistry @@ -18,7 +19,7 @@ import { } from './tool'; import { captureSnapshot } from './snapshot'; -export class AppWidget extends Panel { +export class AppWidget extends Panel implements IAppWidget { constructor(options: AppWidget.IOptions) { super(); this.node.id = options.id; @@ -68,7 +69,7 @@ export class AppWidget extends Panel { return this._ready.promise; } - get model(): AppModel { + get model(): IAppModel { return this._model; } @@ -180,7 +181,7 @@ export class AppWidget extends Panel { this.removeSpinner(); } - async turnOffStaticRender() { + async turnOffStaticRender(): Promise { if (!this._model.staticRender) { return; } @@ -235,7 +236,7 @@ export class AppWidget extends Panel { spectaConfig: this._spectaAppConfig }); } - private _model: AppModel; + private _model: IAppModel; private _ready = new PromiseDelegate(); @@ -254,7 +255,7 @@ export namespace AppWidget { export interface IOptions { id: string; label: string; - model: AppModel; + model: IAppModel; layoutRegistry: ISpectaLayoutRegistry; spectaConfig: ISpectaAppConfig; } diff --git a/src/specta_widget_factory.ts b/src/specta_widget_factory.ts index fabaedd..5a9ad64 100644 --- a/src/specta_widget_factory.ts +++ b/src/specta_widget_factory.ts @@ -14,7 +14,7 @@ import { ServiceManager, KernelSpec } from '@jupyterlab/services'; import { AppModel } from './specta_model'; import { AppWidget } from './specta_widget'; import { UUID } from '@lumino/coreutils'; -import { ISpectaLayoutRegistry } from './token'; +import { IAppWidget, ISpectaLayoutRegistry } from './token'; import { readSpectaConfig } from './tool'; export class SpectaWidgetFactory { @@ -24,7 +24,7 @@ export class SpectaWidgetFactory { async createNew(options: { context: DocumentRegistry.IContext; - }): Promise { + }): Promise { const { context } = options; const rendermime = this._options.rendermime.clone({ resolver: context.urlResolver diff --git a/src/token.ts b/src/token.ts index 3666208..493b9f1 100644 --- a/src/token.ts +++ b/src/token.ts @@ -3,8 +3,14 @@ import { Panel, Widget } from '@lumino/widgets'; import { SpectaCellOutput } from './specta_cell_output'; import * as nbformat from '@jupyterlab/nbformat'; import { ISignal } from '@lumino/signaling'; +import { IDisposable } from '@lumino/disposable'; import { IWidgetTracker } from '@jupyterlab/apputils'; import { JupyterFrontEnd } from '@jupyterlab/application'; +import { ICellModel } from '@jupyterlab/cells'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { CellList, INotebookModel, NotebookPanel } from '@jupyterlab/notebook'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import type { ISpectaSnapshotData } from './snapshot'; export interface ISpectaShell extends JupyterFrontEnd.IShell { hideTopBar: () => void; @@ -96,6 +102,165 @@ export interface ISpectaWidget { readonly isAttached: boolean; } +/** + * The state of the render cache stored in the notebook metadata relative to + * the notebook itself. + */ +export type ISpectaSnapshotStatus = 'out-of-sync' | 'in-sync' | 'not-exist'; + +/** + * The public API of the Specta app model. + * + * Import this instead of the concrete `AppModel` class wherever only typing + * is needed, so consumers do not pull the implementation into their bundle. + */ +export interface IAppModel extends IDisposable { + /** + * The rendermime registry the preview renders outputs with. + */ + readonly rendermime: IRenderMimeRegistry; + + /** + * The cells of the sandbox notebook, if it has been created. + */ + readonly cells: CellList | undefined; + + /** + * The sandbox context the preview renders from — a throwaway clone whose + * `save()` is a no-op, not the document context written to disk. + */ + readonly sandboxContext: + DocumentRegistry.IContext | undefined; + + /** + * The notebook panel backing the sandbox context. + */ + readonly panel: NotebookPanel | undefined; + + /** + * Whether the model renders from the cached snapshot instead of a kernel. + */ + readonly staticRender: boolean; + + /** + * A signal emitted with the re-seeded cells when the document changes. + */ + readonly fileChanged: ISignal; + + /** + * A signal emitted when the render cache or static render mode changes. + */ + readonly snapshotChanged: ISignal; + + /** + * Create the sandbox context and notebook panel. + */ + initialize(): Promise; + + /** + * Create the output widget for a cell. + */ + createCell(cellModel: ICellModel): SpectaCellOutput; + + /** + * Leave static render mode and re-initialize against a live kernel. + */ + turnOffStaticRender(): Promise; + + /** + * Bring the sandbox in line with the document, if it has drifted. + * + * Returns the re-seeded cell list, or `undefined` when the sandbox already + * matches. + */ + resyncSandbox(): CellList | undefined; + + /** + * Execute a code cell into the given output wrapper. + */ + executeCell(cell: ICellModel, outputWrapper: SpectaCellOutput): Promise; + + /** + * The render cache stored in the notebook metadata, if any. + */ + getSnapshot(): ISpectaSnapshotData | undefined; + + /** + * Whether the render cache matches the current notebook sources. + */ + snapshotStatus(): ISpectaSnapshotStatus; + + /** + * Write a render cache to the notebook metadata and save the document. + */ + saveSnapshotToMetadata(snapshot: ISpectaSnapshotData): Promise; + + /** + * Remove the render cache from the notebook metadata and save the document. + */ + deleteSnapshot(): Promise; +} + +/** + * The public API of the Specta app widget. + * + * Import this instead of the concrete `AppWidget` class wherever only typing + * is needed, so consumers do not pull the implementation into their bundle. + */ +export interface IAppWidget extends Widget { + /** + * A promise that is fulfilled when the model is ready. + */ + readonly ready: Promise; + + /** + * The model driving this widget. + */ + readonly model: IAppModel; + + /** + * Add the loading spinner. + */ + addSpinner(): void; + + /** + * Remove the loading spinner. + */ + removeSpinner(): void; + + /** + * Create and start executing the output widgets for the given cells. + */ + generateOutputs(cellList?: CellList): Promise; + + /** + * The layout this widget renders with. + */ + getLayout(): ISpectaLayout; + + /** + * Render the notebook into the host panel. + */ + render(): Promise; + + /** + * Discard the current outputs and render again. + */ + rerender(newCells?: CellList): Promise; + + /** + * Leave static render mode and render against a live kernel. + */ + turnOffStaticRender(): Promise; + + /** + * Capture the current outputs into the render cache. + * + * Returns the snapshot timestamp, or `undefined` if nothing was saved. + */ + saveSnapshot(): Promise; +} + export interface ISpectaTopbarWidget { addTopbarWidget?: ( widget: ISpectaWidget, diff --git a/src/topbar/menuComponent.tsx b/src/topbar/menuComponent.tsx index c03a938..f59c1bd 100644 --- a/src/topbar/menuComponent.tsx +++ b/src/topbar/menuComponent.tsx @@ -5,8 +5,12 @@ import { ISignal } from '@lumino/signaling'; import { GearIcon } from '../components/icon/gear'; import { IconButton } from '../components/iconButton'; import { SettingContent } from './settingDialog'; -import { ISpectaUiSwitcher, ITopbarConfig, ISpectaWidget } from '../token'; -import type { AppWidget } from '../specta_widget'; +import { + IAppWidget, + ISpectaUiSwitcher, + ITopbarConfig, + ISpectaWidget +} from '../token'; interface IProps { config?: ITopbarConfig; @@ -14,7 +18,7 @@ interface IProps { settingsWidgets?: ISpectaWidget[]; uiSwitcher?: ISpectaUiSwitcher | null; currentPath?: string | null; - spectaWidget?: AppWidget; + spectaWidget?: IAppWidget; currentUi?: string; settingsIconChanged?: ISignal; customIcon?: JSX.Element; diff --git a/src/topbar/settingDialog.tsx b/src/topbar/settingDialog.tsx index 17f8bfb..4da69f2 100644 --- a/src/topbar/settingDialog.tsx +++ b/src/topbar/settingDialog.tsx @@ -2,13 +2,13 @@ import { IThemeManager } from '@jupyterlab/apputils'; import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Divider } from '../components/divider'; import { + IAppWidget, ISpectaLayoutRegistry, ISpectaUiSwitcher, ITopbarConfig, ISpectaWidget } from '../token'; import { Widget } from '@lumino/widgets'; -import type { AppWidget } from '../specta_widget'; import { StaticRenderingSection } from './staticRenderingSection'; export const SettingContent = (props: { @@ -19,7 +19,7 @@ export const SettingContent = (props: { uiSwitcher?: ISpectaUiSwitcher | null; currentPath?: string | null; currentUi?: string; - spectaWidget?: AppWidget; + spectaWidget?: IAppWidget; isSpectaApp?: boolean; enableStaticRenderingConfig?: boolean; }) => { diff --git a/src/topbar/staticRenderingSection.tsx b/src/topbar/staticRenderingSection.tsx index cae5f83..3ed9622 100644 --- a/src/topbar/staticRenderingSection.tsx +++ b/src/topbar/staticRenderingSection.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import type { AppModel } from '../specta_model'; -import type { AppWidget } from '../specta_widget'; +import { IAppModel, IAppWidget } from '../token'; type ISnapshotState = { status: 'out-of-sync' | 'in-sync' | 'not-exist'; @@ -9,7 +8,7 @@ type ISnapshotState = { staticRender: boolean; }; -function readSnapshotState(model?: AppModel): ISnapshotState { +function readSnapshotState(model?: IAppModel): ISnapshotState { return { status: model?.snapshotStatus() ?? 'not-exist', timestamp: model?.getSnapshot()?.timestamp, @@ -18,7 +17,7 @@ function readSnapshotState(model?: AppModel): ISnapshotState { } export const StaticRenderingSection = (props: { - spectaWidget?: AppWidget; + spectaWidget?: IAppWidget; isSpectaApp?: boolean; }) => { const model = props.spectaWidget?.model; From de98be962d168e0a5e6f32cf25e6ab2aa483793e Mon Sep 17 00:00:00 2001 From: Duc Trung Le Date: Wed, 19 Aug 2026 23:31:46 +0200 Subject: [PATCH 2/6] add more dialogs --- schema/app-meta.json | 10 + src/document/factory.tsx | 4 +- src/specta_model.ts | 14 + src/token.ts | 18 +- src/tool.ts | 6 + src/topbar/index.tsx | 2 +- src/topbar/menuComponent.tsx | 18 +- src/topbar/settingDialog.tsx | 409 +++++++++++++------------- src/topbar/staticRenderingSection.tsx | 82 +++++- 9 files changed, 331 insertions(+), 232 deletions(-) diff --git a/schema/app-meta.json b/schema/app-meta.json index e83416f..985c89d 100644 --- a/schema/app-meta.json +++ b/schema/app-meta.json @@ -51,6 +51,12 @@ "white" ], "default": null + }, + "/specta/enableStaticRendering": { + "type": "string", + "enum": ["Yes", "No"], + "default": "No", + "title": "Enable static rendering" } } }, @@ -74,6 +80,10 @@ "/specta/topbarThemeToggle": { "metadataLevel": "notebook", "writeDefault": false + }, + "/specta/enableStaticRendering": { + "metadataLevel": "notebook", + "writeDefault": false } } } diff --git a/src/document/factory.tsx b/src/document/factory.tsx index c16b4f2..477dd13 100644 --- a/src/document/factory.tsx +++ b/src/document/factory.tsx @@ -54,7 +54,7 @@ export class NotebookGridWidgetFactory extends ABCWidgetFactory< nbMetadata: context.model.metadata, nbPath: path }); - + console.log('aaaaaaaaa', spectaConfig); const spectaWidget = await this._spectaWidgetFactory.createNew({ context }); @@ -75,7 +75,7 @@ export class NotebookGridWidgetFactory extends ABCWidgetFactory< const menu = ( { + const currentSpectaConfig = + this.options.context.model.getMetadata('specta'); + currentSpectaConfig.enableStaticRendering = value ? 'Yes' : 'No'; + console.log('setEnableStaticRendering', currentSpectaConfig); + this.options.context.model.setMetadata('specta', currentSpectaConfig); + await this.options.context.save(); + this.options.context.model.dirty = false; + this._snapshotChanged.emit(); + } + async turnOffStaticRender(): Promise { if (!this._staticRender) { return; @@ -378,6 +389,9 @@ export class AppModel implements IAppModel { private _documentJson(): INotebookContent { const json = this.options.context.model.toJSON() as INotebookContent; + // sandbox context does not need metadata, this is to avoid + // reloading specta view when changing specta metadata + delete json.metadata['specta']; delete json.metadata[SPECTA_SNAPSHOT_KEY]; return json; } diff --git a/src/token.ts b/src/token.ts index 493b9f1..91119d5 100644 --- a/src/token.ts +++ b/src/token.ts @@ -58,6 +58,7 @@ export interface ISpectaAppConfig { loadingName?: string; executionDelay?: number; uiSwitcherOptions?: IUiOption[]; + enableStaticRendering?: boolean; labConfig?: { setSingleMode?: boolean; hideLeftPanel?: boolean; @@ -108,12 +109,6 @@ export interface ISpectaWidget { */ export type ISpectaSnapshotStatus = 'out-of-sync' | 'in-sync' | 'not-exist'; -/** - * The public API of the Specta app model. - * - * Import this instead of the concrete `AppModel` class wherever only typing - * is needed, so consumers do not pull the implementation into their bundle. - */ export interface IAppModel extends IDisposable { /** * The rendermime registry the preview renders outputs with. @@ -199,14 +194,13 @@ export interface IAppModel extends IDisposable { * Remove the render cache from the notebook metadata and save the document. */ deleteSnapshot(): Promise; + + /** + * Save static rendering preference to the notebook metadata + */ + setEnableStaticRendering(value: boolean): Promise; } -/** - * The public API of the Specta app widget. - * - * Import this instead of the concrete `AppWidget` class wherever only typing - * is needed, so consumers do not pull the implementation into their bundle. - */ export interface IAppWidget extends Widget { /** * A promise that is fulfilled when the model is ready. diff --git a/src/tool.ts b/src/tool.ts index 9f88f56..04ee833 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -316,6 +316,12 @@ export function readSpectaConfig({ delete spectaMetadata.topbarThemeToggle; } + if (spectaMetadata.enableStaticRendering === 'Yes') { + spectaMetadata.enableStaticRendering = true; + } else { + spectaMetadata.enableStaticRendering = false; + } + // merge spectaConfig and spectaMetadata (spectaMetadata has higher priority return mergeObjects(spectaConfig, spectaMetadata); } diff --git a/src/topbar/index.tsx b/src/topbar/index.tsx index ff7fc15..72ea386 100644 --- a/src/topbar/index.tsx +++ b/src/topbar/index.tsx @@ -68,7 +68,7 @@ export const topbarPlugin: JupyterFrontEndPlugin< } const menu = ( { + disableOutsideClick.current = value; + }, []); + useEffect(() => { setCustomIcon(props.customIcon); }, [props.customIcon]); @@ -41,6 +47,7 @@ export function MenuComponent(props: IProps): JSX.Element { useEffect(() => { const handleClickOutside = (e: any) => { if ( + !disableOutsideClick.current && dialogRef.current && !dialogRef.current.contains(e.target) && buttonRef.current && @@ -83,7 +90,7 @@ export function MenuComponent(props: IProps): JSX.Element {
)} diff --git a/src/topbar/settingDialog.tsx b/src/topbar/settingDialog.tsx index 4da69f2..4dece89 100644 --- a/src/topbar/settingDialog.tsx +++ b/src/topbar/settingDialog.tsx @@ -5,198 +5,226 @@ import { IAppWidget, ISpectaLayoutRegistry, ISpectaUiSwitcher, - ITopbarConfig, - ISpectaWidget + ISpectaWidget, + ISpectaAppConfig } from '../token'; import { Widget } from '@lumino/widgets'; import { StaticRenderingSection } from './staticRenderingSection'; -export const SettingContent = (props: { - config?: ITopbarConfig; - themeManager?: IThemeManager; - layoutRegistry?: ISpectaLayoutRegistry; - settingsWidgets?: ISpectaWidget[]; - uiSwitcher?: ISpectaUiSwitcher | null; - currentPath?: string | null; - currentUi?: string; - spectaWidget?: IAppWidget; - isSpectaApp?: boolean; - enableStaticRenderingConfig?: boolean; -}) => { - const { themeManager, layoutRegistry, settingsWidgets } = props; - const [themeOptions, setThemeOptions] = useState([ - ...(themeManager?.themes ?? []) - ]); - const [selectedTheme, setSelectedTheme] = useState( - themeManager?.theme ?? 'light' - ); +export const SettingContent = React.memo( + (props: { + spectaConfig?: ISpectaAppConfig; + themeManager?: IThemeManager; + layoutRegistry?: ISpectaLayoutRegistry; + settingsWidgets?: ISpectaWidget[]; + uiSwitcher?: ISpectaUiSwitcher | null; + currentPath?: string | null; + currentUi?: string; + spectaWidget?: IAppWidget; + isSpectaApp?: boolean; + enableStaticRenderingConfig?: boolean; + disableOutsideClickTest: (value: boolean) => void; + }) => { + const { themeManager, layoutRegistry, settingsWidgets } = props; + const [themeOptions, setThemeOptions] = useState([ + ...(themeManager?.themes ?? []) + ]); + const [selectedTheme, setSelectedTheme] = useState( + themeManager?.theme ?? 'light' + ); - const [layoutOptions, setLayoutOptions] = useState( - layoutRegistry?.allLayouts() ?? [] - ); - const [selectedLayout, setSelectedLayout] = useState( - layoutRegistry?.selectedLayout?.name ?? 'article' - ); - useEffect(() => { - let cb: any; - if (themeManager) { - cb = (sender: IThemeManager, args: any) => { - if (args.newValue.length > 0) { - return; - } - - setThemeOptions([...themeManager.themes]); - - if (themeManager.theme) { - setSelectedTheme(themeManager.theme); - } - }; - themeManager.themeChanged.connect(cb); - } - if (layoutRegistry) { - const layoutAddedCb = ( - sender: ISpectaLayoutRegistry, - newLayout: string - ) => { - setLayoutOptions(layoutRegistry.allLayouts()); - }; + const [layoutOptions, setLayoutOptions] = useState( + layoutRegistry?.allLayouts() ?? [] + ); + const [selectedLayout, setSelectedLayout] = useState( + layoutRegistry?.selectedLayout?.name ?? 'article' + ); + useEffect(() => { + let cb: any; + if (themeManager) { + cb = (sender: IThemeManager, args: any) => { + if (args.newValue.length > 0) { + return; + } - layoutRegistry.layoutAdded.connect(layoutAddedCb); - } + setThemeOptions([...themeManager.themes]); - return () => { - if (themeManager && cb) { - themeManager.themeChanged.disconnect(cb); + if (themeManager.theme) { + setSelectedTheme(themeManager.theme); + } + }; + themeManager.themeChanged.connect(cb); } - }; - }, [themeManager, layoutRegistry]); + if (layoutRegistry) { + const layoutAddedCb = ( + sender: ISpectaLayoutRegistry, + newLayout: string + ) => { + setLayoutOptions(layoutRegistry.allLayouts()); + }; - const onThemeChange = useCallback( - (e: React.ChangeEvent) => { - const theme = e.currentTarget?.value; - if (theme) { - themeManager?.setTheme(theme); - setSelectedTheme(theme); - } - }, - [themeManager] - ); - const onLayoutChange = useCallback( - (e: React.ChangeEvent) => { - const layout = e.currentTarget?.value; - if (layout && layoutRegistry) { - layoutRegistry.setSelectedLayout(layout); - setSelectedLayout(layout); + layoutRegistry.layoutAdded.connect(layoutAddedCb); } - }, - [layoutRegistry] - ); - // Defer widget attachment to prevent 'pointerdown' violation warnings. - const frameRef = useRef(null); - const customWidgetsRef = useCallback( - (node: HTMLDivElement | null) => { - if (frameRef.current !== null) { - cancelAnimationFrame(frameRef.current); - frameRef.current = null; - } + return () => { + if (themeManager && cb) { + themeManager.themeChanged.disconnect(cb); + } + }; + }, [themeManager, layoutRegistry]); - if (node) { - node.innerHTML = ''; - if (settingsWidgets) { - frameRef.current = requestAnimationFrame(() => { + const onThemeChange = useCallback( + (e: React.ChangeEvent) => { + const theme = e.currentTarget?.value; + if (theme) { + themeManager?.setTheme(theme); + setSelectedTheme(theme); + } + }, + [themeManager] + ); + const onLayoutChange = useCallback( + (e: React.ChangeEvent) => { + const layout = e.currentTarget?.value; + if (layout && layoutRegistry) { + layoutRegistry.setSelectedLayout(layout); + setSelectedLayout(layout); + } + }, + [layoutRegistry] + ); + // Defer widget attachment to prevent 'pointerdown' violation warnings. + const frameRef = useRef(null); + + const customWidgetsRef = useCallback( + (node: HTMLDivElement | null) => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + + if (node) { + node.innerHTML = ''; + if (settingsWidgets) { + frameRef.current = requestAnimationFrame(() => { + settingsWidgets.forEach(w => { + if (w.isAttached) { + Widget.detach(w as Widget); + } + Widget.attach(w as Widget, node); + }); + frameRef.current = null; + }); + } + } else { + if (settingsWidgets) { settingsWidgets.forEach(w => { if (w.isAttached) { Widget.detach(w as Widget); } - Widget.attach(w as Widget, node); }); - frameRef.current = null; - }); - } - } else { - if (settingsWidgets) { - settingsWidgets.forEach(w => { - if (w.isAttached) { - Widget.detach(w as Widget); - } - }); + } } - } - }, - [settingsWidgets] - ); + }, + [settingsWidgets] + ); - const { uiSwitcher, currentPath } = props; - const onUiChange = useCallback( - (e: React.ChangeEvent) => { - const ui = e.currentTarget?.value; - if (ui && uiSwitcher && currentPath) { - uiSwitcher.switchTo(currentPath, ui); - } - }, - [uiSwitcher, currentPath] - ); + const { uiSwitcher, currentPath } = props; + const onUiChange = useCallback( + (e: React.ChangeEvent) => { + const ui = e.currentTarget?.value; + if (ui && uiSwitcher && currentPath) { + uiSwitcher.switchTo(currentPath, ui); + } + }, + [uiSwitcher, currentPath] + ); - return ( -
-

- SPECTA MENU -

- - {(props.config?.layoutToggle !== undefined - ? props.config.layoutToggle - : true) && - layoutRegistry && ( -
- -
- + return ( +
+

+ SPECTA MENU +

+ + {(props.spectaConfig?.topBar?.layoutToggle !== undefined + ? props.spectaConfig?.topBar.layoutToggle + : true) && + layoutRegistry && ( +
+ +
+ +
-
- )} - {(props.config?.themeToggle !== undefined - ? props.config.themeToggle - : true) && - themeManager && ( + )} + {(props.spectaConfig?.topBar?.themeToggle !== undefined + ? props.spectaConfig?.topBar?.themeToggle + : true) && + themeManager && ( +
+ +
+ +
+
+ )} + {currentPath && uiSwitcher && uiSwitcher.uis.length > 0 && (
- {uiSwitcher.uis.map(ui => { - return ( - - ); - })} - + {props.enableStaticRenderingConfig && ( + + )} + {settingsWidgets && settingsWidgets.length > 0 && ( +
+ +
-
- )} - {props.enableStaticRenderingConfig && ( - - )} - {settingsWidgets && settingsWidgets.length > 0 && ( -
- -
-
- )} -
- ); -}; + )} +
+ ); + } +); + +SettingContent.displayName = 'SettingContent'; diff --git a/src/topbar/staticRenderingSection.tsx b/src/topbar/staticRenderingSection.tsx index 3ed9622..49750c8 100644 --- a/src/topbar/staticRenderingSection.tsx +++ b/src/topbar/staticRenderingSection.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { IAppModel, IAppWidget } from '../token'; +import { IAppModel, IAppWidget, ISpectaAppConfig } from '../token'; +import { showDialog, Dialog } from '@jupyterlab/apputils'; type ISnapshotState = { status: 'out-of-sync' | 'in-sync' | 'not-exist'; @@ -19,13 +20,19 @@ function readSnapshotState(model?: IAppModel): ISnapshotState { export const StaticRenderingSection = (props: { spectaWidget?: IAppWidget; isSpectaApp?: boolean; + spectaConfig?: ISpectaAppConfig; + disableOutsideClickTest: (value: boolean) => void; }) => { - const model = props.spectaWidget?.model; + const { isSpectaApp, spectaConfig, disableOutsideClickTest, spectaWidget } = + props; + const model = spectaWidget?.model; const [snapshotState, setSnapshotState] = useState(() => readSnapshotState(model) ); const [creatingSnapshot, setCreatingSnapshot] = useState(false); - + const [enableStaticRendering, setEnableStaticRendering] = useState( + Boolean(spectaConfig?.enableStaticRendering) + ); const { status: snapshotStatus, timestamp: currentTimestamp, @@ -49,35 +56,84 @@ export const StaticRenderingSection = (props: { if (snapshotStatus === 'not-exist') { return; } + disableOutsideClickTest(true); + const response = await showDialog({ + title: 'Delete render cache', + body: 'Do you want to delete the current render cache?', + buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Delete' })] + }); + disableOutsideClickTest(false); + if (response.button.accept !== true) { + return; + } await model?.deleteSnapshot(); - }, [model, snapshotStatus]); + }, [model, snapshotStatus, disableOutsideClickTest]); const createSnapshot = useCallback(async () => { if (isStaticRendering || creatingRef.current) { return; } + + disableOutsideClickTest(true); + const response = await showDialog({ + title: 'Save render cache', + body: 'A cache of the current notebook outputs will be saved. This will allow users to view the notebook without a running kernel, but all kernel-based interactions will be disabled.', + buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Save' })] + }); + disableOutsideClickTest(false); + if (response.button.accept !== true) { + return; + } creatingRef.current = true; setCreatingSnapshot(true); try { - await props.spectaWidget?.saveSnapshot(); + await spectaWidget?.saveSnapshot(); } finally { creatingRef.current = false; setCreatingSnapshot(false); } - }, [props.spectaWidget, isStaticRendering]); + }, [spectaWidget, isStaticRendering, disableOutsideClickTest]); const activateKernel = useCallback(async () => { if (!isStaticRendering) { return; } - await props.spectaWidget?.turnOffStaticRender(); - }, [props.spectaWidget, isStaticRendering]); + await spectaWidget?.turnOffStaticRender(); + }, [spectaWidget, isStaticRendering]); + const onStaticRenderingChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value === 'on'; + setEnableStaticRendering(value); + spectaWidget?.model.setEnableStaticRendering(value); + }, + [spectaWidget] + ); return (
+
+ +
+
+ Current render mode:{' '} + {isStaticRendering ? 'Saved cache' : 'Live kernel'} +
{snapshotStatus !== 'not-exist' && (
Last cache:{' '} @@ -107,7 +167,7 @@ export const StaticRenderingSection = (props: { gap: '8px', flexDirection: 'row', marginBottom: '4px', - display: props.isSpectaApp ? 'none' : 'flex' + display: isSpectaApp ? 'none' : 'flex' }} > -
+ {spectaWidget && isStaticRendering && ( +
+ +
+ )}
);