diff --git a/galata/test/documentation/commands.test.ts-snapshots/commandsList-documentation-linux.json b/galata/test/documentation/commands.test.ts-snapshots/commandsList-documentation-linux.json index 20498bf59fb5..51bc81fc54e0 100644 --- a/galata/test/documentation/commands.test.ts-snapshots/commandsList-documentation-linux.json +++ b/galata/test/documentation/commands.test.ts-snapshots/commandsList-documentation-linux.json @@ -441,6 +441,12 @@ "Enter" ] }, + { + "id": "console:setup-custom-env", + "label": "Launch kernel with custom env vars...", + "caption": "Launch kernel with custom env vars...", + "shortcuts": [] + }, { "id": "console:shutdown", "label": "Shut Down", @@ -980,6 +986,12 @@ "caption": "", "shortcuts": [] }, + { + "id": "filebrowser:toggle-single-click-navigation", + "label": "Enable Single Click Navigation", + "caption": "", + "shortcuts": [] + }, { "id": "filebrowser:toggle-sort-notebooks-first", "label": "Sort Notebooks Above Files", @@ -2031,6 +2043,12 @@ "caption": "", "shortcuts": [] }, + { + "id": "notebook:setup-custom-env", + "label": "Launch kernel with custom env vars...", + "caption": "Launch kernel with custom env vars...", + "shortcuts": [] + }, { "id": "notebook:show-all-cell-code", "label": "Expand All Code", @@ -2211,6 +2229,12 @@ "caption": "", "shortcuts": [] }, + { + "id": "running:kernel-shut-down-unused", + "label": "Shut Down Unused", + "caption": "", + "shortcuts": [] + }, { "id": "running:show-modal", "label": "Search Tabs and Running Sessions", diff --git a/galata/test/documentation/debugger.test.ts-snapshots/debugger-stop-on-raised-exception-documentation-linux.png b/galata/test/documentation/debugger.test.ts-snapshots/debugger-stop-on-raised-exception-documentation-linux.png index 2bb70d93ebff..5abd999ba094 100644 Binary files a/galata/test/documentation/debugger.test.ts-snapshots/debugger-stop-on-raised-exception-documentation-linux.png and b/galata/test/documentation/debugger.test.ts-snapshots/debugger-stop-on-raised-exception-documentation-linux.png differ diff --git a/galata/test/documentation/general.test.ts-snapshots/command-palette-documentation-linux.png b/galata/test/documentation/general.test.ts-snapshots/command-palette-documentation-linux.png index b2693d35b23e..b6cc3a296651 100644 Binary files a/galata/test/documentation/general.test.ts-snapshots/command-palette-documentation-linux.png and b/galata/test/documentation/general.test.ts-snapshots/command-palette-documentation-linux.png differ diff --git a/galata/test/documentation/internationalization.test.ts-snapshots/language-change-documentation-linux.png b/galata/test/documentation/internationalization.test.ts-snapshots/language-change-documentation-linux.png index 0f25aeadbfd4..b9dc8fbbeb19 100644 Binary files a/galata/test/documentation/internationalization.test.ts-snapshots/language-change-documentation-linux.png and b/galata/test/documentation/internationalization.test.ts-snapshots/language-change-documentation-linux.png differ diff --git a/galata/test/documentation/internationalization.test.ts-snapshots/language-settings-documentation-linux.png b/galata/test/documentation/internationalization.test.ts-snapshots/language-settings-documentation-linux.png index eca8c4bca5a0..98627e51cbb4 100644 Binary files a/galata/test/documentation/internationalization.test.ts-snapshots/language-settings-documentation-linux.png and b/galata/test/documentation/internationalization.test.ts-snapshots/language-settings-documentation-linux.png differ diff --git a/packages/apputils/src/sessioncontext.tsx b/packages/apputils/src/sessioncontext.tsx index 3f4e23f03d16..dccf0c4ec528 100644 --- a/packages/apputils/src/sessioncontext.tsx +++ b/packages/apputils/src/sessioncontext.tsx @@ -1,7 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { IChangedArgs, PathExt } from '@jupyterlab/coreutils'; +import { IChangedArgs, PageConfig, PathExt } from '@jupyterlab/coreutils'; import { Kernel, KernelMessage, @@ -16,12 +16,18 @@ import { TranslationBundle } from '@jupyterlab/translation'; import { find } from '@lumino/algorithm'; -import { JSONExt, PromiseDelegate, UUID } from '@lumino/coreutils'; +import { + JSONExt, + PartialJSONObject, + PromiseDelegate, + UUID +} from '@lumino/coreutils'; import { IDisposable, IObservableDisposable } from '@lumino/disposable'; import { ISignal, Signal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; import * as React from 'react'; import { Dialog, showDialog } from './dialog'; +import { CustomEnvWidget } from '@jupyterlab/ui-components'; /** * A context object to manage a widget's kernel session connection. @@ -301,6 +307,11 @@ export namespace ISessionContext { * Skip showing the kernel restart dialog if checked (default `false`). */ readonly skipKernelRestartDialog?: boolean; + + /** + * Custom kernel variables + */ + customEnvVars?: undefined | PartialJSONObject; } export type KernelDisplayStatus = @@ -710,6 +721,9 @@ export class SessionContext implements ISessionContext { }); if (name) { options = { name }; + if (preference.customEnvVars) { + options.env = preference.customEnvVars; + } } } @@ -767,7 +781,10 @@ export class SessionContext implements ISessionContext { // and start its kernel first to ensure consistent // ordering. await this._initStarted.promise; - return this._changeKernel(options); + const test = this._changeKernel(options); + console.log('test'); + console.dir(test); + return test; } /** @@ -1407,6 +1424,16 @@ export class SessionContextDialogs implements ISessionContext.IDialogs { return; } + const model = result.value as Kernel.IModel; + + if (model && sessionContext.kernelPreference?.customEnvVars) { + sessionContext.kernelPreference.customEnvVars = undefined; + } + + if (model.env) { + sessionContext.kernelPreference.customEnvVars = model.env; + } + if (hasCheckbox && result.isChecked !== null) { sessionContext.kernelPreference = { ...sessionContext.kernelPreference, @@ -1414,7 +1441,6 @@ export class SessionContextDialogs implements ISessionContext.IDialogs { }; } - const model = result.value; if (model === null && !sessionContext.hasNoKernel) { return sessionContext.shutdown(); } @@ -1812,21 +1838,55 @@ namespace Private { export const createKernelSelector = ( sessionContext: ISessionContext, translator?: ITranslator - ) => - new KernelSelector({ - node: createSelectorNode(sessionContext, translator) - }); + ) => new KernelSelector(sessionContext, translator); /** * A widget that provides a kernel selection. */ - class KernelSelector extends Widget { + export class KernelSelector extends Widget { + sessionContext: ISessionContext; + translator: ITranslator | undefined; + + constructor(sessionContext: ISessionContext, translator?: ITranslator) { + super({ node: createSelectorNode(sessionContext, translator) }); + this.sessionContext = sessionContext; + this.translator = translator; + } /** * Get the value of the kernel selector widget. */ getValue(): Kernel.IModel { const selector = this.node.querySelector('select') as HTMLSelectElement; - return JSON.parse(selector.value) as Kernel.IModel; + let kernelData = JSON.parse(selector.value) as Kernel.IModel; + let tmp = {} as PartialJSONObject; + const envVarsBlock = this.node.querySelector( + 'div.jp-custom-env-vars-block' + ); + + if (envVarsBlock) { + let customEnvParameters = envVarsBlock.getAttribute( + 'data-custom-env-vars' + ); + if (customEnvParameters) { + let envParameters = JSON.parse( + customEnvParameters + ) as PartialJSONObject; + + for (let index in envParameters) { + let env = envParameters[index] as PartialJSONObject; + let envName: string = + env && typeof env.name === 'string' ? env.name : ''; + let envValue: string = + env && typeof env.value === 'string' ? env.value : ('' as string); + if (envName && envValue) { + tmp[envName] = envValue; + } + } + + kernelData['env'] = tmp; + } + } + return kernelData; } } @@ -1853,6 +1913,15 @@ namespace Private { sessionContext, translator ); + + const acceptKernelEnvVar = + PageConfig.getOption('accept_kernel_env_var') === 'true' + ? true + : false; + const envVarsDiv = document.createElement('div'); + envVarsDiv.setAttribute('class', 'jp-custom-env-vars-block'); + envVarsDiv.setAttribute('data-custom-env-vars', ''); + if (options.disabled) select.disabled = true; for (const group of options.groups) { const { label, options } = group; @@ -1860,7 +1929,17 @@ namespace Private { optgroup.label = label; for (const { selected, text, title, value } of options) { const option = document.createElement('option'); - if (selected) option.selected = true; + if (selected) { + option.selected = true; + let val = JSON.parse(value); + let id = val && val.id ? val.id : ''; + if (acceptKernelEnvVar && !id) { + let defaultEnvValue = {}; + envVarsDiv.innerHTML = ''; + addEnvBlock(envVarsDiv, defaultEnvValue, translator); + } + } + if (title) option.title = title; option.text = text; option.value = value; @@ -1868,10 +1947,46 @@ namespace Private { } select.appendChild(optgroup); } + + select.onchange = () => { + let kernelData = JSON.parse(select.value) as Kernel.IModel; + envVarsDiv.innerHTML = ''; + body.setAttribute('data-custom-env-vars', ''); + if (acceptKernelEnvVar && !kernelData.id) { + let defaultEnvValue = {}; + addEnvBlock(envVarsDiv, defaultEnvValue, translator); + } + }; body.appendChild(select); + body.appendChild(envVarsDiv); return body; } + function addEnvBlock( + body: HTMLDivElement, + defaultEnvValues: PartialJSONObject, + translator?: ITranslator + ) { + let envConfiguration: PartialJSONObject = {}; + body.setAttribute('data-custom-env-vars', ''); + + let customEnvBlock = new CustomEnvWidget( + envConfiguration, + defaultEnvValues, + formData => { + envConfiguration = formData as PartialJSONObject; + body.setAttribute( + 'data-custom-env-vars', + JSON.stringify(envConfiguration) + ); + }, + false, + translator + ); + + Widget.attach(customEnvBlock, body); + } + /** * Get the default kernel name given select options. */ diff --git a/packages/apputils/style/dialog.css b/packages/apputils/style/dialog.css index be48d3cd04c4..14824a6299f5 100644 --- a/packages/apputils/style/dialog.css +++ b/packages/apputils/style/dialog.css @@ -90,6 +90,11 @@ button.jp-Dialog-close-button { min-height: unset; } +button.jp-Dialog-button.jp-mod-styled.js-custom-env { + margin-left: 5px; + margin-top: 5px; +} + .jp-Dialog-header { display: flex; justify-content: space-between; @@ -151,3 +156,14 @@ button.jp-Dialog-close-button { .jp-Dialog-button.jp-mod-styled:not(:last-child) { margin-right: 12px; } + +.jp-Dialog-body .js-Dialog-form-custom-env span { + display: inline-block; + width: 40px; + margin-right: 10px; +} + +.jp-Dialog-body .js-Dialog-form-custom-env input { + margin-top: 10px; + margin-bottom: 10px; +} diff --git a/packages/apputils/test/sessioncontext.spec.ts b/packages/apputils/test/sessioncontext.spec.ts index 538cc5072dbd..19abe6a02845 100644 --- a/packages/apputils/test/sessioncontext.spec.ts +++ b/packages/apputils/test/sessioncontext.spec.ts @@ -71,6 +71,8 @@ describe('@jupyterlab/apputils', () => { // eslint-disable-next-line camelcase allow_external_kernels: true, // eslint-disable-next-line camelcase + accept_kernel_env_var: true, + // eslint-disable-next-line camelcase external_connection_dir: external } } @@ -165,6 +167,25 @@ describe('@jupyterlab/apputils', () => { }); }); + describe('#kernelChanged with custom env variables', () => { + it('should be emitted when the kernel changes', async () => { + let called = false; + sessionContext.kernelChanged.connect( + (sender, { oldValue, newValue }) => { + if (oldValue !== null) { + return; + } + expect(sender).toBe(sessionContext); + expect(oldValue).toBeNull(); + expect(newValue).toBe(sessionContext.session?.kernel || null); + called = true; + } + ); + await sessionContext.initialize(); + expect(called).toBe(true); + }); + }); + describe('#sessionChanged', () => { it('should be emitted when the session changes', async () => { let called = false; @@ -477,6 +498,16 @@ describe('@jupyterlab/apputils', () => { expect(kernel.name).toBe(name); }); + it('should change the current kernel and have custom_env_vars', async () => { + await sessionContext.initialize(); + + const name = sessionContext.session?.kernel?.name; + const id = sessionContext.session?.kernel?.id; + const env = { TEST_ENV_NAME: 'test_env_value' }; + const kernel = (await sessionContext.changeKernel({ name, env }))!; + expect(kernel.id).not.toBe(id); + }); + it('should still work if called before fully initialized', async () => { const initPromise = sessionContext.initialize(); // Start but don't finish init. const name = 'echo'; diff --git a/packages/console-extension/src/index.ts b/packages/console-extension/src/index.ts index cb98a2e93c8b..abdf3d0b67b8 100644 --- a/packages/console-extension/src/index.ts +++ b/packages/console-extension/src/index.ts @@ -42,6 +42,7 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { consoleIcon, + CustomEnvWidget, IFormRendererRegistry, redoIcon, undoIcon @@ -50,6 +51,7 @@ import { find } from '@lumino/algorithm'; import { JSONExt, JSONObject, + PartialJSONObject, ReadonlyJSONValue, ReadonlyPartialJSONObject, UUID @@ -58,6 +60,8 @@ import { DisposableSet } from '@lumino/disposable'; import { DockLayout, Widget } from '@lumino/widgets'; import foreign from './foreign'; import { cellExecutor } from './cellexecutor'; +import { PageConfig } from '@jupyterlab/coreutils'; +import { ISpecModel } from '@jupyterlab/services/src/kernelspec/restapi'; /** * The command IDs used by the console plugin. @@ -102,6 +106,8 @@ namespace CommandIDs { export const invokeCompleter = 'completer:invoke-console'; export const selectCompleter = 'completer:select-console'; + + export const setupCustomEnv = 'console:setup-custom-env'; } /** @@ -297,6 +303,108 @@ async function activateConsole( when: manager.ready }); } + const showCustomEnvVarsDialog = async ( + spec: ISpecModel | undefined, + basePath: string, + args: ReadonlyPartialJSONObject + ) => { + let envConfiguration: PartialJSONObject = {}; + let newArgs = { ...args }; + let label = trans.__('Cancel'); + const buttons = [ + Dialog.cancelButton({ + label + }), + Dialog.okButton({ + label: trans.__('Setup'), + ariaLabel: trans.__('Setup custom env variables') + }) + ]; + + let defaultEnvValues = {}; + const dialog = new Dialog({ + title: '', + body: new CustomEnvWidget( + envConfiguration, + defaultEnvValues, + formData => { + envConfiguration = formData as PartialJSONObject; + }, + true, + translator + ), + buttons + }); + + const result = await dialog.launch(); + + if (!result.button.accept) { + return; + } + + if (Object.keys(envConfiguration).length > 0 && spec) { + let tmp = {} as PartialJSONObject; + for (let index in envConfiguration) { + let env = envConfiguration[index] as PartialJSONObject; + let envName: string = + env && typeof env.name === 'string' ? env.name : ''; + let envValue: string = + env && typeof env.value === 'string' ? env.value : ('' as string); + if (envName && envValue) { + tmp[envName] = envValue; + } + } + // add { isLauncher: true, kernelPreference: { name } }, + if (args.kernelPreference) { + let kernelPreference = + args.kernelPreference as ReadonlyPartialJSONObject; + newArgs['kernelPreference'] = { + ...kernelPreference, + customEnvVars: tmp + }; + } + + return createConsole({ basePath, ...newArgs }); + } + }; + + const LAUNCHER_LABEL = 'jp-LauncherCard'; + const isLauncherLabel = (node: HTMLElement) => + node.classList.contains(LAUNCHER_LABEL); + + app.commands.addCommand(CommandIDs.setupCustomEnv, { + label: trans.__('Launch kernel with custom env vars...'), + caption: trans.__('Launch kernel with custom env vars...'), + execute: async (args?: any) => { + const node = app.contextMenuHitTest(isLauncherLabel); + if (!node) { + return; + } + const specs = manager.kernelspecs.specs; + if (!specs) { + return; + } + let selectedSpec: ISpecModel | undefined; + let kernelName = ''; + + let defaultName = node.innerText; + for (const name in specs.kernelspecs) { + const spec = specs.kernelspecs[name]; + if (spec && spec.display_name === defaultName) { + selectedSpec = spec; + kernelName = name; + } + } + + const basePath = + ((args['basePath'] as string) || + (args['cwd'] as string) || + filebrowser?.model.path) ?? + ''; + let kernelPreference = { kernelPreference: { name: kernelName } }; + return showCustomEnvVarsDialog(selectedSpec, basePath, kernelPreference); + } + }); // Add a launcher item if the launcher is available. if (launcher) { @@ -338,6 +446,18 @@ async function activateConsole( }); } + const acceptKernelEnvVar = + PageConfig.getOption('accept_kernel_env_var') === 'true' + ? true + : false; + if (acceptKernelEnvVar) { + app.contextMenu.addItem({ + command: CommandIDs.setupCustomEnv, + selector: '.jp-LauncherCard[data-category="Console"]', + rank: 0 + }); + } + /** * The options used to create a widget. */ diff --git a/packages/console-extension/tsconfig.json b/packages/console-extension/tsconfig.json index 9464f3d11f93..f3ce542b41db 100644 --- a/packages/console-extension/tsconfig.json +++ b/packages/console-extension/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../console" }, + { + "path": "../coreutils" + }, { "path": "../filebrowser" }, @@ -33,6 +36,9 @@ { "path": "../rendermime" }, + { + "path": "../services" + }, { "path": "../settingregistry" }, diff --git a/packages/console/src/panel.ts b/packages/console/src/panel.ts index ddbb20e69070..1036c72d749a 100644 --- a/packages/console/src/panel.ts +++ b/packages/console/src/panel.ts @@ -91,9 +91,14 @@ export class ConsolePanel extends MainAreaWidget { void sessionContext.initialize().then(async value => { if (value) { - await ( - options.sessionDialogs ?? new SessionContextDialogs({ translator }) - ).selectKernel(sessionContext!); + let dialog = await (options.sessionDialogs ?? + new SessionContextDialogs({ translator })); + if ( + options.kernelPreference && + !options.kernelPreference.customEnvVars + ) { + dialog.selectKernel(sessionContext!); + } } this._connected = new Date(); this._updateTitlePanel(); diff --git a/packages/docregistry/src/context.ts b/packages/docregistry/src/context.ts index 0ee10fbd3e3a..25f976efa1c2 100644 --- a/packages/docregistry/src/context.ts +++ b/packages/docregistry/src/context.ts @@ -23,7 +23,11 @@ import { TranslationBundle } from '@jupyterlab/translation'; -import { PartialJSONValue, PromiseDelegate } from '@lumino/coreutils'; +import { + PartialJSONObject, + PartialJSONValue, + PromiseDelegate +} from '@lumino/coreutils'; import { DisposableDelegate, IDisposable } from '@lumino/disposable'; import { ISignal, Signal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; @@ -540,7 +544,9 @@ export class Context< /** * Handle an initial population. */ - private async _populate(): Promise { + private async _populate( + customEnvVars?: undefined | PartialJSONObject + ): Promise { this._isPopulated = true; this._isReady = true; this._populatedPromise.resolve(void 0); @@ -550,6 +556,14 @@ export class Context< if (this.isDisposed) { return; } + + if ( + (!customEnvVars || Object.keys(customEnvVars).length === 0) && + this.sessionContext.kernelPreference.customEnvVars + ) { + customEnvVars = this.sessionContext.kernelPreference.customEnvVars; + } + // Update the kernel preference. const name = this._model.defaultKernelName || @@ -557,7 +571,8 @@ export class Context< this.sessionContext.kernelPreference = { ...this.sessionContext.kernelPreference, name, - language: this._model.defaultKernelLanguage + language: this._model.defaultKernelLanguage, + customEnvVars: customEnvVars }; // Note: we don't wait on the session to initialize // so that the user can be shown the content before diff --git a/packages/docregistry/src/registry.ts b/packages/docregistry/src/registry.ts index ca9049af5a5a..71a0c2791d0b 100644 --- a/packages/docregistry/src/registry.ts +++ b/packages/docregistry/src/registry.ts @@ -643,6 +643,7 @@ export class DocumentRegistry implements IDisposable { const language = modelFactory.preferredLanguage(PathExt.basename(path)); const name = kernel && kernel.name; const id = kernel && kernel.id; + const customEnvVars = kernel && kernel.env; return { id, name, @@ -650,7 +651,8 @@ export class DocumentRegistry implements IDisposable { shouldStart: widgetFactory.preferKernel, canStart: widgetFactory.canStartKernel, shutdownOnDispose: widgetFactory.shutdownOnClose, - autoStartDefault: widgetFactory.autoStartDefault + autoStartDefault: widgetFactory.autoStartDefault, + customEnvVars: customEnvVars ? customEnvVars : undefined }; } diff --git a/packages/notebook-extension/src/index.ts b/packages/notebook-extension/src/index.ts index ed24f02c9335..0119f848183a 100644 --- a/packages/notebook-extension/src/index.ts +++ b/packages/notebook-extension/src/index.ts @@ -102,6 +102,7 @@ import { addBelowIcon, buildIcon, copyIcon, + CustomEnvWidget, cutIcon, duplicateIcon, fastForwardIcon, @@ -121,6 +122,7 @@ import { CommandRegistry } from '@lumino/commands'; import { JSONExt, JSONObject, + PartialJSONObject, ReadonlyJSONValue, ReadonlyPartialJSONObject, UUID @@ -135,6 +137,7 @@ import { CellMetadataField, NotebookMetadataField } from './tool-widgets/metadataEditorFields'; +import { ISpecModel } from '@jupyterlab/services/src/kernelspec/restapi'; /** * The command IDs used by the notebook plugin. @@ -328,6 +331,8 @@ namespace CommandIDs { export const accessNextHistory = 'notebook:access-next-history-entry'; export const virtualScrollbar = 'notebook:toggle-virtual-scrollbar'; + + export const setupCustomEnv = 'notebook:setup-custom-env'; } /** @@ -1938,7 +1943,8 @@ function activateNotebookHandler( const createNew = async ( cwd: string, kernelId: string, - kernelName: string + kernelName: string, + customEnvVars?: undefined | PartialJSONObject ) => { const model = await commands.execute('docmanager:new-untitled', { path: cwd, @@ -1948,7 +1954,11 @@ function activateNotebookHandler( const widget = (await commands.execute('docmanager:open', { path: model.path, factory: FACTORY, - kernel: { id: kernelId, name: kernelName } + kernel: { + id: kernelId, + name: kernelName, + env: customEnvVars + } })) as unknown as IDocumentWidget; widget.isUntitled = true; return widget; @@ -1982,6 +1992,105 @@ function activateNotebookHandler( } }); + const showCustomEnvVarsDialog = async ( + spec: ISpecModel | undefined, + node: HTMLElement, + cwd: string, + kernelId: string, + kernelName: string + ) => { + let envConfiguration: PartialJSONObject = {}; + let label = trans.__('Cancel'); + const buttons = [ + Dialog.cancelButton({ + label + }), + Dialog.okButton({ + label: trans.__('Setup'), + ariaLabel: trans.__('Setup custom env variables') + }) + ]; + + let defaultEnvValues = {}; + const dialog = new Dialog({ + title: '', + body: new CustomEnvWidget( + envConfiguration, + defaultEnvValues, + formData => { + envConfiguration = formData as PartialJSONObject; + }, + true, + translator + ), + buttons + }); + + const result = await dialog.launch(); + + if (!result.button.accept) { + return; + } + + if (Object.keys(envConfiguration).length > 0 && spec) { + let tmp = {} as PartialJSONObject; + for (let index in envConfiguration) { + let env = envConfiguration[index] as PartialJSONObject; + let envName: string = + env && typeof env.name === 'string' ? env.name : ''; + let envValue: string = + env && typeof env.value === 'string' ? env.value : ('' as string); + if (envName && envValue) { + tmp[envName] = envValue; + } + } + return createNew(cwd, kernelId, kernelName, tmp); + } + }; + + const LAUNCHER_LABEL = 'jp-LauncherCard'; + const isLauncherLabel = (node: HTMLElement) => + node.classList.contains(LAUNCHER_LABEL); + + // add command for context menu on Launch app icon + app.commands.addCommand(CommandIDs.setupCustomEnv, { + label: trans.__('Launch kernel with custom env vars...'), + caption: trans.__('Launch kernel with custom env vars...'), + execute: async (args?: any) => { + const node = app.contextMenuHitTest(isLauncherLabel); + if (!node) { + return; + } + const specs = services.kernelspecs.specs; + if (!specs) { + return; + } + let selectedSpec: ISpecModel | undefined; + let kernelName = ''; + + let defaultName = node.innerText; + for (const name in specs.kernelspecs) { + const spec = specs.kernelspecs[name]; + if (spec && spec.display_name === defaultName) { + selectedSpec = spec; + kernelName = name; + } + } + const currentBrowser = + filebrowserFactory?.tracker.currentWidget ?? defaultBrowser; + const cwd = (args['cwd'] as string) || (currentBrowser?.model.path ?? ''); + const kernelId = (args['kernelId'] as string) || ''; + + return showCustomEnvVarsDialog( + selectedSpec, + node, + cwd, + kernelId, + kernelName + ); + } + }); + // Add a launcher item if the launcher is available. if (launcher) { void services.ready.then(() => { @@ -2021,6 +2130,18 @@ function activateNotebookHandler( onSpecsChanged(); services.kernelspecs.specsChanged.connect(onSpecsChanged); }); + + const acceptKernelEnvVar = + PageConfig.getOption('accept_kernel_env_var') === 'true' + ? true + : false; + if (allowCustomEnvVariables) { + app.contextMenu.addItem({ + command: CommandIDs.setupCustomEnv, + selector: '.jp-LauncherCard[data-category="Notebook"]', + rank: 0 + }); + } } return tracker; diff --git a/packages/services/src/kernel/restapi.ts b/packages/services/src/kernel/restapi.ts index f2399cc1f3d8..c3fb66140392 100644 --- a/packages/services/src/kernel/restapi.ts +++ b/packages/services/src/kernel/restapi.ts @@ -4,6 +4,7 @@ import { ServerConnection } from '../serverconnection'; import { URLExt } from '@jupyterlab/coreutils'; import { validateModel, validateModels } from './validate'; +import { PartialJSONObject } from '@lumino/coreutils'; /** * The kernel model provided by the server. @@ -47,6 +48,8 @@ export interface IModel { * The traceback for a dead kernel, if applicable. */ readonly traceback?: string; + + env?: undefined | PartialJSONObject; } /** diff --git a/packages/ui-components/src/components/customenvvars.tsx b/packages/ui-components/src/components/customenvvars.tsx new file mode 100644 index 000000000000..32b07a7dc1f0 --- /dev/null +++ b/packages/ui-components/src/components/customenvvars.tsx @@ -0,0 +1,238 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { ReactWidget } from './vdom'; +import { PartialJSONObject } from '@lumino/coreutils'; +import React, { + ChangeEvent, + ChangeEventHandler, + useEffect, + useState +} from 'react'; +import { Button } from './button'; + +interface IEnvProps { + updateFormData: (formData: PartialJSONObject) => void; + defaultEnvValues: PartialJSONObject; + showBlock: boolean; + translator?: ITranslator | undefined; +} + +interface IEnvBlockProps { + handleChange: (envVars: PartialJSONObject) => void; + id: string; + defaultName: string; + defaultEnvValue: string; + translator: ITranslator | undefined; +} + +export function EnvBlock({ + handleChange, + id, + defaultName, + defaultEnvValue, + translator +}: IEnvBlockProps) { + const [newEnvName, setEnvName] = useState(defaultName); + const [newEnvValue, setEnvValue] = useState(defaultEnvValue); + translator = translator || nullTranslator; + const trans = translator.load('jupyterlab'); + + useEffect(() => { + let envVar = {} as PartialJSONObject; + envVar[id] = { + name: newEnvName, + value: newEnvValue + }; + handleChange(envVar); + }, [newEnvName, newEnvValue]); + + const onChange: ChangeEventHandler = ( + event: ChangeEvent + ) => { + const { name, value } = event.target; + if (name === 'env_name') { + setEnvName(value); + } else if (name === 'env_value') { + setEnvValue(value); + } + }; + + return ( + <> + +
+ +
+ + ); +} + +function CustomEnv({ + updateFormData, + defaultEnvValues, + showBlock, + translator +}: IEnvProps) { + const [formData, setInputs] = useState(defaultEnvValues); + const [isShownBlock, setShowBlock] = useState(showBlock); + const [countEnvBlock, setCountEnvBlock] = useState(1); + + translator = translator || nullTranslator; + const trans = translator.load('jupyterlab'); + + const addMoreEnvVariables = () => { + let newCountEnvBlock = countEnvBlock + 1; + setCountEnvBlock(newCountEnvBlock); + }; + + const handleChange = (envVars: PartialJSONObject) => { + let newForm = { + ...formData, + ...envVars + }; + setInputs(newForm); + updateFormData(newForm); + }; + + const showCustomEnvBlock = () => { + setShowBlock(!isShownBlock); + }; + + let envBlock = []; + for (let index = 1; index <= countEnvBlock; index++) { + let envData = formData[`${index}`] as PartialJSONObject | undefined; + + let defaultName = envData && envData.name ? envData.name : ''; + let defaultEnvValue = envData && envData.value ? envData.value : ''; + + envBlock.push( + + ); + } + + const header = `${trans.__('Setup custom env variables')}`; + const addMoreVarLabel = trans.__('Add more'); + + const classes = 'jp-Dialog-button jp-mod-accept jp-mod-styled js-custom-env'; + + return ( +
+ {!showBlock && ( + + )} + {showBlock &&
{header}
} + {isShownBlock && ( + <> +
{envBlock}
+ + + )} +
+ ); +} + +export default CustomEnv; + +/** + * A Dialog Widget that wraps a form component for custom env variables. + */ +export class CustomEnvWidget extends ReactWidget { + updateFormData: (formData: PartialJSONObject) => void; + envConfiguration: PartialJSONObject; + defaultEnvValues: PartialJSONObject; + showBlock: boolean; + translator?: ITranslator | undefined; + /** + * Constructs a new custom env variables widget. + */ + constructor( + envConfiguration: PartialJSONObject, + defaultEnvValues: PartialJSONObject, + updateFormData: (formData: PartialJSONObject) => void, + showBlock: boolean, + translator?: ITranslator + ) { + super(); + this.envConfiguration = envConfiguration; + this.updateFormData = updateFormData; + this.translator = translator; + this.defaultEnvValues = defaultEnvValues; + this.showBlock = showBlock; + this.addClass('jp-custom-env-widget'); + } + + getValue(): PartialJSONObject { + return this.envConfiguration; + } + + render(): JSX.Element { + let k = 1; + let tmp = {} as PartialJSONObject; + for (let index in this.defaultEnvValues) { + let envVarsIndex = `${k}`; + tmp[envVarsIndex] = { + name: index, + value: this.defaultEnvValues[index] + }; + k += 1; + } + let showBlock = this.showBlock; + if (Object.keys(tmp).length > 0) { + showBlock = true; + } + return ( + + ); + } +} diff --git a/packages/ui-components/src/components/index.ts b/packages/ui-components/src/components/index.ts index b4b3cd986b26..f2125ebfab93 100644 --- a/packages/ui-components/src/components/index.ts +++ b/packages/ui-components/src/components/index.ts @@ -19,3 +19,4 @@ export * from './table'; export * from './toolbar'; export * from './vdom'; export * from './windowedlist'; +export * from './customenvvars'; diff --git a/packages/ui-components/test/customenvvars.spec.ts b/packages/ui-components/test/customenvvars.spec.ts new file mode 100644 index 000000000000..bdefa17dcbc4 --- /dev/null +++ b/packages/ui-components/test/customenvvars.spec.ts @@ -0,0 +1,66 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { framePromise } from '@jupyterlab/testing'; +import { CustomEnvWidget } from '@jupyterlab/ui-components'; +import { PartialJSONObject } from '@lumino/coreutils'; +import { Widget } from '@lumino/widgets'; +import { nullTranslator } from '@jupyterlab/translation'; + +describe('#Custom Env Variables', () => { + describe('Custom Env Variables', () => { + let translator = nullTranslator; + + it('should render a custom env vars widget', async () => { + let envConfiguration: PartialJSONObject = {}; + const widget = new CustomEnvWidget( + envConfiguration, + {}, + formData => { + envConfiguration = formData as PartialJSONObject; + document.body.setAttribute( + 'data-custom-env-vars', + JSON.stringify(envConfiguration) + ); + }, + true, + translator + ); + + Widget.attach(widget, document.body); + await framePromise(); + await widget.renderPromise; + + let form = widget.node + .getElementsByClassName('js-Dialog-form-custom-env') + .item(0); + expect(form).not.toBeNull(); + }); + + it('should render a checkbox for showing custom env vars widget', async () => { + let envConfiguration: PartialJSONObject = {}; + const widget = new CustomEnvWidget( + envConfiguration, + {}, + formData => { + envConfiguration = formData as PartialJSONObject; + document.body.setAttribute( + 'data-custom-env-vars', + JSON.stringify(envConfiguration) + ); + }, + false, + translator + ); + + Widget.attach(widget, document.body); + await framePromise(); + await widget.renderPromise; + let checkbox = widget.node + .getElementsByClassName('jp-Dialog-checkbox') + .item(0); + const title = 'Setup custom env variables'; + expect(checkbox?.textContent).toBe(title); + }); + }); +});