diff --git a/package.json b/package.json index 42c2355..108a1d7 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,8 @@ "outputDir": "jupyterlab_new_launcher/labextension", "schemaDir": "schema", "disabledExtensions": [ - "@jupyterlab/launcher-extension" + "@jupyterlab/launcher-extension", + "@jupyterlab/apputils-extension:sessionDialogs" ] }, "eslintIgnore": [ diff --git a/src/database.ts b/src/database.ts index cca479d..4a2195b 100644 --- a/src/database.ts +++ b/src/database.ts @@ -1,6 +1,15 @@ import { IStateDB } from '@jupyterlab/statedb'; import { ReadonlyPartialJSONValue, PromiseDelegate } from '@lumino/coreutils'; import { ILauncher } from '@jupyterlab/launcher'; +import { + ILastUsedDatabase, + IFavoritesDatabase, + ILauncherDatabase +} from './types'; +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; abstract class Database { ready: Promise; @@ -56,3 +65,55 @@ export abstract class ItemDatabase< return item.command + '_' + JSON.stringify(item.args); } } + +export class LastUsedDatabase + extends ItemDatabase + implements ILastUsedDatabase +{ + protected _stateDBKey = 'new-launcher:last-used'; + + get(item: ILauncher.IItemOptions) { + const date = super._get(item); + return date ? new Date(date) : null; + } + + async recordAsUsedNow(item: ILauncher.IItemOptions) { + this._set(item, new Date().toUTCString()); + } +} + +export class FavoritesDatabase + extends ItemDatabase + implements IFavoritesDatabase +{ + protected _stateDBKey = 'new-launcher:favorites'; + + get(item: ILauncher.IItemOptions) { + return super._get(item) ?? null; + } + + async set(item: ILauncher.IItemOptions, isFavourite: boolean) { + this._set(item, isFavourite); + } +} + +/** + * Initialization data for the jupyterlab-new-launcher extension. + */ +export const databasePlugin: JupyterFrontEndPlugin = { + id: 'jupyterlab-new-launcher:database', + description: 'A redesigned JupyterLab launcher databases', + provides: ILauncherDatabase, + autoStart: true, + requires: [IStateDB], + activate: (app: JupyterFrontEnd, stateDB: IStateDB) => { + const databaseOptions = { + stateDB, + fetchInterval: 10000 + }; + return { + lastUsed: new LastUsedDatabase(databaseOptions), + favorites: new FavoritesDatabase(databaseOptions) + }; + } +}; diff --git a/src/dialogs.tsx b/src/dialogs.tsx new file mode 100644 index 0000000..7ff215f --- /dev/null +++ b/src/dialogs.tsx @@ -0,0 +1,228 @@ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import type { CommandRegistry } from '@lumino/commands'; +import { + SessionContextDialogs, + ISessionContextDialogs, + ISessionContext, + SessionContext, + Dialog, + ReactWidget +} from '@jupyterlab/apputils'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { + ITranslator, + nullTranslator, + TranslationBundle +} from '@jupyterlab/translation'; + +import { KernelTable } from './launcher'; +import { + IItem, + ILastUsedDatabase, + IFavoritesDatabase, + ILauncherDatabase, + MAIN_PLUGIN_ID +} from './types'; +import { Item } from './item'; +import type { ILauncher } from '@jupyterlab/launcher'; +import { JSONExt, ReadonlyJSONValue } from '@lumino/coreutils'; +import * as React from 'react'; + +class CustomSessionContextDialogs extends SessionContextDialogs { + constructor(protected options: CustomSessionContextDialogs.IOptions) { + super(options); + const translator = options.translator ?? nullTranslator; + this.trans = translator.load('jupyterlab'); + } + /** + * Select a kernel for the session. + */ + async selectKernel(sessionContext: ISessionContext): Promise { + const trans = this.trans; + if (sessionContext.isDisposed) { + return Promise.resolve(); + } + + // If there is no existing kernel, offer the option + // to keep no kernel. + let label = trans.__('Cancel'); + if (sessionContext.hasNoKernel) { + label = sessionContext.kernelDisplayName; + } + const buttons = [ + Dialog.cancelButton({ + label + }), + Dialog.okButton({ + label: trans.__('Select'), + ariaLabel: trans.__('Select Kernel') + }) + ]; + + const autoStartDefault = sessionContext.kernelPreference.autoStartDefault; + const hasCheckbox = typeof autoStartDefault === 'boolean'; + const settings = await this.options.settingRegistry.load(MAIN_PLUGIN_ID); + + const dialog = new Dialog({ + title: trans.__('Select Kernel'), + body: new KernelSelector({ + data: { + specs: sessionContext.specsManager.specs, + sessions: sessionContext.sessionManager.running(), + preference: sessionContext.kernelPreference + }, + commands: this.options.commands, + favoritesDatabase: this.options.database.favorites, + lastUsedDatabase: this.options.database.lastUsed, + settings, + trans + }), + buttons, + checkbox: hasCheckbox + ? { + label: trans.__('Always start the preferred kernel'), + caption: trans.__( + 'Remember my choice and always start the preferred kernel' + ), + checked: autoStartDefault + } + : null + }); + + const result = await dialog.launch(); + + if (sessionContext.isDisposed || !result.button.accept) { + return; + } + + if (hasCheckbox && result.isChecked !== null) { + sessionContext.kernelPreference = { + ...sessionContext.kernelPreference, + autoStartDefault: result.isChecked + }; + } + + const model = result.value; + if (model === null && !sessionContext.hasNoKernel) { + return sessionContext.shutdown(); + } + if (model) { + await sessionContext.changeKernel(model); + } + } + private trans: TranslationBundle; +} + +export namespace CustomSessionContextDialogs { + export interface IOptions extends ISessionContext.IDialogsOptions { + database: ILauncherDatabase; + commands: CommandRegistry; + settingRegistry: ISettingRegistry; + } +} + +/** + * Initialization data for the jupyterlab-new-launcher session dialogs. + */ +export const sessionDialogsPlugin: JupyterFrontEndPlugin = + { + id: 'jupyterlab-new-launcher:dialogs', + description: 'Session dialogs for redesigned JupyterLab launcher', + provides: ISessionContextDialogs, + autoStart: true, + requires: [ITranslator, ILauncherDatabase, ISettingRegistry], + activate: ( + app: JupyterFrontEnd, + translator: ITranslator, + database: ILauncherDatabase, + settingRegistry: ISettingRegistry + ) => { + return new CustomSessionContextDialogs({ + translator: translator, + database: database, + commands: app.commands, + settingRegistry: settingRegistry + }); + } + }; + +export class KernelSelector extends ReactWidget { + constructor(protected options: KernelSelector.IOptions) { + super(); + this.commands = options.commands; + this._lastUsedDatabase = options.lastUsedDatabase; + this._favoritesDatabase = options.favoritesDatabase; + this._settings = options.settings; + this.trans = options.trans; + } + private _lastUsedDatabase: ILastUsedDatabase; + private _favoritesDatabase: IFavoritesDatabase; + trans: TranslationBundle; + + renderKernelCommand = (item: ILauncher.IItemOptions): IItem => { + return new Item({ + item, + cwd: '', + commands: this.commands, + lastUsedDatabase: this._lastUsedDatabase, + favoritesDatabase: this._favoritesDatabase + }); + }; + + /** + * Render the launcher to virtual DOM nodes. + */ + protected render(): React.ReactElement | null { + const items: ILauncher.IItemOptions[] = []; + + for (const [_, spec] of Object.entries( + this.options.data.specs!.kernelspecs! + )) { + if (!spec) { + continue; + } + const kernelIconUrl = + spec.resources['logo-svg'] || spec.resources['logo-64x64']; + items.push({ + command: 'notebook:create-new', + args: { + isLauncher: true, + kernelName: spec.name + }, + kernelIconUrl, + metadata: { + kernel: JSONExt.deepCopy(spec.metadata || {}) as ReadonlyJSONValue + }, + category: this.trans.__('Notebook') + }); + } + const notebookItems = items.map(this.renderKernelCommand); + + return ( + + ); + } + protected commands: CommandRegistry; + private _settings: ISettingRegistry.ISettings; +} + +export namespace KernelSelector { + export interface IOptions { + lastUsedDatabase: ILastUsedDatabase; + favoritesDatabase: IFavoritesDatabase; + settings: ISettingRegistry.ISettings; + commands: CommandRegistry; + trans: TranslationBundle; + data: SessionContext.IKernelSearch; + } +} diff --git a/src/favorites.ts b/src/favorites.ts deleted file mode 100644 index 0506951..0000000 --- a/src/favorites.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ILauncher } from '@jupyterlab/launcher'; -import { ItemDatabase } from './database'; - -export interface IFavoritesDatabase { - get(item: ILauncher.IItemOptions): boolean | null; - set(item: ILauncher.IItemOptions, isFavourite: boolean): Promise; -} - -export class FavoritesDatabase - extends ItemDatabase - implements IFavoritesDatabase -{ - protected _stateDBKey = 'new-launcher:favorites'; - - get(item: ILauncher.IItemOptions) { - return super._get(item) ?? null; - } - - async set(item: ILauncher.IItemOptions, isFavourite: boolean) { - this._set(item, isFavourite); - } -} diff --git a/src/index.ts b/src/index.ts index 48f2f5f..cc8234f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,32 +9,31 @@ import { ICommandPalette, MainAreaWidget } from '@jupyterlab/apputils'; import { FileBrowserModel, IDefaultFileBrowser } from '@jupyterlab/filebrowser'; import { ILauncher, LauncherModel } from '@jupyterlab/launcher'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; -import { IStateDB } from '@jupyterlab/statedb'; import { ITranslator } from '@jupyterlab/translation'; import { addIcon, launcherIcon } from '@jupyterlab/ui-components'; import { find } from '@lumino/algorithm'; import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; import { DockPanel, TabBar, Widget } from '@lumino/widgets'; import { NewLauncher as Launcher } from './launcher'; -import { LastUsedDatabase } from './last_used'; -import { FavoritesDatabase } from './favorites'; -import { CommandIDs } from './types'; +import { CommandIDs, ILauncherDatabase, MAIN_PLUGIN_ID } from './types'; import { addCommands } from './commands'; +import { sessionDialogsPlugin } from './dialogs'; +import { databasePlugin } from './database'; /** * Initialization data for the jupyterlab-new-launcher extension. */ -const plugin: JupyterFrontEndPlugin = { - id: 'jupyterlab-new-launcher:plugin', +const launcherPlugin: JupyterFrontEndPlugin = { + id: MAIN_PLUGIN_ID, description: 'A redesigned JupyterLab launcher', provides: ILauncher, autoStart: true, - requires: [ITranslator, IStateDB, ISettingRegistry], + requires: [ITranslator, ISettingRegistry, ILauncherDatabase], optional: [ILabShell, ICommandPalette, IDefaultFileBrowser], activate }; -export default plugin; +export default [launcherPlugin, sessionDialogsPlugin, databasePlugin]; /** * Activate the launcher. @@ -42,8 +41,8 @@ export default plugin; function activate( app: JupyterFrontEnd, translator: ITranslator, - stateDB: IStateDB, settingRegistry: ISettingRegistry, + database: ILauncherDatabase, labShell: ILabShell | null, palette: ICommandPalette | null, defaultBrowser: IDefaultFileBrowser | null @@ -52,14 +51,7 @@ function activate( const trans = translator.load('jupyterlab-new-launcher'); const model = new LauncherModel(); - const databaseOptions = { - stateDB, - fetchInterval: 10000 - }; - const lastUsedDatabase = new LastUsedDatabase(databaseOptions); - const favoritesDatabase = new FavoritesDatabase(databaseOptions); - - settingRegistry.load(plugin.id).then(settings => { + settingRegistry.load(MAIN_PLUGIN_ID).then(settings => { addCommands(app, trans, settings); }); @@ -77,16 +69,16 @@ function activate( } }; - const settings = await settingRegistry.load(plugin.id); - await Promise.all([lastUsedDatabase.ready, favoritesDatabase.ready]); + const settings = await settingRegistry.load(MAIN_PLUGIN_ID); + await Promise.all([database.lastUsed.ready, database.favorites.ready]); const launcher = new Launcher({ model, cwd, callback, commands, translator, - lastUsedDatabase, - favoritesDatabase, + lastUsedDatabase: database.lastUsed, + favoritesDatabase: database.favorites, settings }); diff --git a/src/item.ts b/src/item.ts new file mode 100644 index 0000000..438c018 --- /dev/null +++ b/src/item.ts @@ -0,0 +1,105 @@ +// Copyright (c) Nebari Development Team. +// Distributed under the terms of the Modified BSD License. +import type { CommandRegistry } from '@lumino/commands'; +import type { VirtualElement } from '@lumino/virtualdom'; +import { ReadonlyJSONObject } from '@lumino/coreutils'; +import { ILauncher } from '@jupyterlab/launcher'; +import { Signal, ISignal } from '@lumino/signaling'; +import { IItem, IFavoritesDatabase, ILastUsedDatabase } from './types'; + +export class Item implements IItem { + // base ILauncher.IItemOptions + command: string; + args?: ReadonlyJSONObject; + category?: string; + rank?: number; + kernelIconUrl?: string; + metadata?: ReadonlyJSONObject; + // custom additions + label: string; + caption: string; + icon: VirtualElement.IRenderer | undefined; + iconClass: string; + starred: boolean; + + constructor( + private _options: { + commands: CommandRegistry; + item: ILauncher.IItemOptions; + cwd: string; + lastUsedDatabase: ILastUsedDatabase; + favoritesDatabase: IFavoritesDatabase; + } + ) { + const { item, commands, lastUsedDatabase, favoritesDatabase, cwd } = + _options; + const args = { ...item.args, cwd }; + // base + this.command = item.command; + this.args = args; + this.category = item.category; + this.rank = item.rank; + this.kernelIconUrl = item.kernelIconUrl; + this.metadata = item.metadata; + // custom + this.iconClass = commands.iconClass(item.command, args); + this.icon = commands.icon(item.command, args); + this.caption = commands.caption(item.command, args); + this.label = commands.label(item.command, args); + this.lastUsed = lastUsedDatabase.get(item); + this.starred = favoritesDatabase.get(item) ?? false; + } + get lastUsed(): Date | null { + return this._lastUsed; + } + set lastUsed(value: Date | null) { + this._lastUsed = value; + this._setRefreshClock(); + } + get refreshLastUsed(): ISignal { + return this._refreshLastUsed; + } + async execute() { + const { item, commands, lastUsedDatabase } = this._options; + await commands.execute(item.command, this.args); + await lastUsedDatabase.recordAsUsedNow(item); + this.lastUsed = lastUsedDatabase.get(item); + this._refreshLastUsed.emit(); + } + toggleStar() { + const { item, favoritesDatabase } = this._options; + const wasStarred = favoritesDatabase.get(item); + const newState = !wasStarred; + this.starred = newState; + return favoritesDatabase.set(item, newState); + } + private _setRefreshClock() { + const value = this._lastUsed; + if (this._refreshClock !== null) { + window.clearTimeout(this._refreshClock); + this._refreshClock = null; + } + if (!value) { + return; + } + const delta = Date.now() - value.getTime(); + // Refresh every 10 seconds if last used less than a minute ago; + // Otherwise refresh every 1 minute if last used less than 1 hour ago + // Otherwise refresh every 1 hour. + const second = 1000; + const minute = 60 * second; + const interval = + delta < 1 * minute + ? 10 * second + : delta < 60 * minute + ? 1 * minute + : 60 * minute; + this._refreshClock = window.setTimeout(() => { + this._refreshLastUsed.emit(); + this._setRefreshClock(); + }, interval); + } + private _refreshLastUsed = new Signal(this); + private _refreshClock: number | null = null; + private _lastUsed: Date | null = null; +} diff --git a/src/last_used.ts b/src/last_used.ts deleted file mode 100644 index 47c149b..0000000 --- a/src/last_used.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ILauncher } from '@jupyterlab/launcher'; -import { ItemDatabase } from './database'; - -export interface ILastUsedDatabase { - get(item: ILauncher.IItemOptions): Date | null; - recordAsUsedNow(item: ILauncher.IItemOptions): Promise; -} - -export class LastUsedDatabase - extends ItemDatabase - implements ILastUsedDatabase -{ - protected _stateDBKey = 'new-launcher:last-used'; - - get(item: ILauncher.IItemOptions) { - const date = super._get(item); - return date ? new Date(date) : null; - } - - async recordAsUsedNow(item: ILauncher.IItemOptions) { - this._set(item, new Date().toUTCString()); - } -} diff --git a/src/launcher.tsx b/src/launcher.tsx index 72c7c40..05c28df 100644 --- a/src/launcher.tsx +++ b/src/launcher.tsx @@ -1,9 +1,9 @@ // Copyright (c) Nebari Development Team. // Distributed under the terms of the Modified BSD License. import type { CommandRegistry } from '@lumino/commands'; -import type { VirtualElement } from '@lumino/virtualdom'; import { ReadonlyJSONObject } from '@lumino/coreutils'; import { Time } from '@jupyterlab/coreutils'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ILauncher, Launcher } from '@jupyterlab/launcher'; import { TranslationBundle } from '@jupyterlab/translation'; import { @@ -15,13 +15,17 @@ import { UseSignal, MenuSvg } from '@jupyterlab/ui-components'; -import { Signal, ISignal } from '@lumino/signaling'; import * as React from 'react'; -import { ILastUsedDatabase } from './last_used'; -import { IFavoritesDatabase } from './favorites'; -import { ISettingsLayout, CommandIDs } from './types'; +import { + ISettingsLayout, + CommandIDs, + IItem, + IKernelItem, + ILastUsedDatabase, + IFavoritesDatabase +} from './types'; import { starIcon } from './icons'; -import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { Item } from './item'; const STAR_BUTTON_CLASS = 'jp-starIconButton'; const KERNEL_ITEM_CLASS = 'jp-TableKernelItem'; @@ -52,22 +56,6 @@ function TypeCard(props: { ); } -interface IItem extends ILauncher.IItemOptions { - label: string; - caption: string; - icon: VirtualElement.IRenderer | undefined; - iconClass: string; - execute: () => Promise; - lastUsed: Date | null; - starred: boolean; - toggleStar: () => void; - refreshLastUsed: ISignal; -} - -interface IKernelItem extends IItem { - //kernel: string; -} - function CollapsibleSection( props: React.PropsWithChildren<{ title: string; @@ -136,11 +124,94 @@ function LauncherBody(props: { const { trans, cwd, typeItems } = props; const [query, updateQuery] = React.useState(''); + const metadataAvailable = new Set(); + for (const item of props.notebookItems) { + const kernelMetadata = item.metadata?.kernel; + if (!kernelMetadata) { + continue; + } + for (const key of Object.keys(kernelMetadata)) { + metadataAvailable.add(key); + } + } + + return ( +
+

+ {trans.__('Launch New Session')} +

+
+

+ {trans.__('Current directory:')} {cwd ? cwd : '/'} +

+
+
+ { + updateQuery(query ?? ''); + }} + initialQuery={''} + useFuzzyFilter={false} + /> +
+ + {typeItems + .filter( + item => + !query || + item.label.toLowerCase().indexOf(query.toLowerCase()) !== -1 + ) + .map(item => ( + + ))} + + + + +
+ ); +} + +export function KernelTable(props: { + trans: TranslationBundle; + items: IKernelItem[]; + commands: CommandRegistry; + settings: ISettingRegistry.ISettings; + showSearchBox: boolean; + query: string; +}) { + const { trans } = props; + let query: string; + let updateQuery: (value: string) => void; + if (props.showSearchBox) { + const [_query, _updateQuery] = React.useState(''); + query = _query; + updateQuery = _updateQuery; + } else { + query = props.query; + } + // Hoisted to avoid "Rendered fewer hooks than expected" error on toggling the Star column const [, forceUpdate] = React.useReducer(x => x + 1, 0); const metadataAvailable = new Set(); - for (const item of props.notebookItems) { + for (const item of props.items) { const kernelMetadata = item.metadata?.kernel; if (!kernelMetadata) { continue; @@ -313,7 +384,7 @@ function LauncherBody(props: { (props.settings.composite.columnOrder as ISettingsLayout['columnOrder']) ?? initialColumnOrder ); - const KernelTable = Table; + const KernelItemTable = Table; const onSettings = () => { const newHiddenColumns = @@ -338,123 +409,95 @@ function LauncherBody(props: { }); return ( -
-

- {trans.__('Launch New Session')} -

-
-

- {trans.__('Current directory:')} {cwd ? cwd : '/'} -

-
-
- { - updateQuery(query ?? ''); - }} - initialQuery={''} - useFuzzyFilter={false} - /> -
- - {typeItems - .filter( - item => - !query || - item.label.toLowerCase().indexOf(query.toLowerCase()) !== -1 - ) - .map(item => ( - - ))} - - -
{ - event.preventDefault(); - const contextMenu = new MenuSvg({ commands: props.commands }); - const columnsSubMenu = new MenuSvg({ commands: props.commands }); - for (const column of columns) { - columnsSubMenu.addItem({ - command: CommandIDs.toggleColumn, - args: { id: column.id, label: column.label } - }); - } - columnsSubMenu.title.label = trans.__('Visible Columns'); +
+ {props.showSearchBox ? ( +
+ { + updateQuery(query ?? ''); + }} + initialQuery={''} + useFuzzyFilter={false} + /> +
+ ) : null} +
{ + event.preventDefault(); + const contextMenu = new MenuSvg({ commands: props.commands }); + const columnsSubMenu = new MenuSvg({ commands: props.commands }); + for (const column of columns) { + columnsSubMenu.addItem({ + command: CommandIDs.toggleColumn, + args: { id: column.id, label: column.label } + }); + } + columnsSubMenu.title.label = trans.__('Visible Columns'); + contextMenu.addItem({ + type: 'submenu', + submenu: columnsSubMenu + }); + const id = ( + (event.target as HTMLElement).closest('th[data-id]') as HTMLElement + )?.dataset['id']; + if (id) { + contextMenu.addItem({ + command: CommandIDs.moveColumn, + args: { direction: 'left', order: columnOrder, id } + }); contextMenu.addItem({ - type: 'submenu', - submenu: columnsSubMenu + command: CommandIDs.moveColumn, + args: { direction: 'right', order: columnOrder, id } }); - const id = ( - (event.target as HTMLElement).closest( - 'th[data-id]' - ) as HTMLElement - )?.dataset['id']; - if (id) { - contextMenu.addItem({ - command: CommandIDs.moveColumn, - args: { direction: 'left', order: columnOrder, id } - }); - contextMenu.addItem({ - command: CommandIDs.moveColumn, - args: { direction: 'right', order: columnOrder, id } - }); + } + contextMenu.open(event.clientX, event.clientY); + }} + > + + kernel.label.toLowerCase().indexOf(query.toLowerCase()) !== -1 + ) + .map(data => { + return { + data: data, + key: data.command + JSON.stringify(data.args) + }; + })} + blankIndicator={() => { + return
{trans.__('No entries')}
; + }} + sortKey={'kernel'} + onRowClick={event => { + const target = event.target as HTMLElement; + const row = target.closest('tr'); + if (!row) { + return; + } + const cell = target.closest('td'); + const starButton = cell?.querySelector(`.${STAR_BUTTON_CLASS}`); + if (starButton) { + return (starButton as HTMLElement).click(); } - contextMenu.open(event.clientX, event.clientY); + const element = row.querySelector(`.${KERNEL_ITEM_CLASS}`)!; + (element as HTMLElement).click(); }} - > - - kernel.label.toLowerCase().indexOf(query.toLowerCase()) !== -1 - ) - .map(data => { - return { - data: data, - key: data.command + JSON.stringify(data.args) - }; - })} - blankIndicator={() => { - return
{trans.__('No entries')}
; - }} - sortKey={'kernel'} - onRowClick={event => { - const target = event.target as HTMLElement; - const row = target.closest('tr'); - if (!row) { - return; - } - const cell = target.closest('td'); - const starButton = cell?.querySelector(`.${STAR_BUTTON_CLASS}`); - if (starButton) { - return (starButton as HTMLElement).click(); - } - const element = row.querySelector(`.${KERNEL_ITEM_CLASS}`)!; - (element as HTMLElement).click(); - }} - columns={columns - .filter(column => !hiddenColumns[column.id]) - .map(column => { - return { - ...column, - rank: columnOrder.indexOf(column.id) ?? 10 - }; - }) - .sort((a, b) => { - return a.rank - b.rank; - })} - /> -
- + columns={columns + .filter(column => !hiddenColumns[column.id]) + .map(column => { + return { + ...column, + rank: columnOrder.indexOf(column.id) ?? 10 + }; + }) + .sort((a, b) => { + return a.rank - b.rank; + })} + /> +
); } @@ -467,103 +510,6 @@ export namespace NewLauncher { } } -class Item implements IItem { - // base ILauncher.IItemOptions - command: string; - args?: ReadonlyJSONObject; - category?: string; - rank?: number; - kernelIconUrl?: string; - metadata?: ReadonlyJSONObject; - // custom additions - label: string; - caption: string; - icon: VirtualElement.IRenderer | undefined; - iconClass: string; - starred: boolean; - - constructor( - private _options: { - commands: CommandRegistry; - item: ILauncher.IItemOptions; - cwd: string; - lastUsedDatabase: ILastUsedDatabase; - favoritesDatabase: IFavoritesDatabase; - } - ) { - const { item, commands, lastUsedDatabase, favoritesDatabase, cwd } = - _options; - const args = { ...item.args, cwd }; - // base - this.command = item.command; - this.args = args; - this.category = item.category; - this.rank = item.rank; - this.kernelIconUrl = item.kernelIconUrl; - this.metadata = item.metadata; - // custom - this.iconClass = commands.iconClass(item.command, args); - this.icon = commands.icon(item.command, args); - this.caption = commands.caption(item.command, args); - this.label = commands.label(item.command, args); - this.lastUsed = lastUsedDatabase.get(item); - this.starred = favoritesDatabase.get(item) ?? false; - } - get lastUsed(): Date | null { - return this._lastUsed; - } - set lastUsed(value: Date | null) { - this._lastUsed = value; - this._setRefreshClock(); - } - get refreshLastUsed(): ISignal { - return this._refreshLastUsed; - } - async execute() { - const { item, commands, lastUsedDatabase } = this._options; - await commands.execute(item.command, this.args); - await lastUsedDatabase.recordAsUsedNow(item); - this.lastUsed = lastUsedDatabase.get(item); - this._refreshLastUsed.emit(); - } - toggleStar() { - const { item, favoritesDatabase } = this._options; - const wasStarred = favoritesDatabase.get(item); - const newState = !wasStarred; - this.starred = newState; - return favoritesDatabase.set(item, newState); - } - private _setRefreshClock() { - const value = this._lastUsed; - if (this._refreshClock !== null) { - window.clearTimeout(this._refreshClock); - this._refreshClock = null; - } - if (!value) { - return; - } - const delta = Date.now() - value.getTime(); - // Refresh every 10 seconds if last used less than a minute ago; - // Otherwise refresh every 1 minute if last used less than 1 hour ago - // Otherwise refresh every 1 hour. - const second = 1000; - const minute = 60 * second; - const interval = - delta < 1 * minute - ? 10 * second - : delta < 60 * minute - ? 1 * minute - : 60 * minute; - this._refreshClock = window.setTimeout(() => { - this._refreshLastUsed.emit(); - this._setRefreshClock(); - }, interval); - } - private _refreshLastUsed = new Signal(this); - private _refreshClock: number | null = null; - private _lastUsed: Date | null = null; -} - export class NewLauncher extends Launcher { constructor(options: NewLauncher.IOptions) { super(options); diff --git a/src/types.ts b/src/types.ts index aaefcc1..889129d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,11 @@ // Copyright (c) Nebari Development Team. // Distributed under the terms of the Modified BSD License. +import type { ILauncher } from '@jupyterlab/launcher'; +import type { VirtualElement } from '@lumino/virtualdom'; +import type { ISignal } from '@lumino/signaling'; +import { Token } from '@lumino/coreutils'; + +export const MAIN_PLUGIN_ID = 'jupyterlab-new-launcher:plugin'; /** * The command IDs used by the launcher plugin. @@ -14,3 +20,47 @@ export interface ISettingsLayout { hiddenColumns: Record; columnOrder: string[]; } + +export interface IItem extends ILauncher.IItemOptions { + label: string; + caption: string; + icon: VirtualElement.IRenderer | undefined; + iconClass: string; + execute: () => Promise; + lastUsed: Date | null; + starred: boolean; + toggleStar: () => void; + refreshLastUsed: ISignal; +} + +export interface IKernelItem extends IItem { + //kernel: string; +} + +export interface ILastUsedDatabase { + ready: Promise; + get(item: ILauncher.IItemOptions): Date | null; + recordAsUsedNow(item: ILauncher.IItemOptions): Promise; +} + +export interface IFavoritesDatabase { + ready: Promise; + get(item: ILauncher.IItemOptions): boolean | null; + set(item: ILauncher.IItemOptions, isFavourite: boolean): Promise; +} + +/** + * Databases for new launcher. + */ +export const ILauncherDatabase = new Token( + 'jupyterlab-new-launcher:ILauncherDatabase', + 'Databases for new launcher.' +); + +/** + *Databases for new launcher + */ +export interface ILauncherDatabase { + lastUsed: ILastUsedDatabase; + favorites: IFavoritesDatabase; +} diff --git a/style/base.css b/style/base.css index 752e2ce..d44b350 100644 --- a/style/base.css +++ b/style/base.css @@ -57,33 +57,42 @@ font-size: 20px; } -.jp-Launcher-openByKernel { +.jp-NewLauncher-table { --jp-icon-size: 16px; + + display: flex; + flex-direction: column; + overflow: hidden; } -.jp-Launcher-openByKernel .jp-Launcher-kernelIcon { +.jp-NewLauncher-table .jp-Launcher-kernelIcon { width: var(--jp-icon-size); height: var(--jp-icon-size); } -.jp-Launcher-openByKernel .jp-LauncherCard-noKernelIcon { +.jp-NewLauncher-table .jp-LauncherCard-noKernelIcon { font-size: var(--jp-icon-size); } -.jp-Launcher-openByKernel .jp-LauncherCard-icon { +.jp-NewLauncher-table .jp-LauncherCard-icon { height: var(--jp-icon-size); } -.jp-Launcher-openByKernel th[data-id='icon'], -.jp-Launcher-openByKernel th[data-id='star'] { +.jp-NewLauncher-table th[data-id='icon'], +.jp-NewLauncher-table th[data-id='star'] { width: var(--jp-icon-size); } -.jp-Launcher-openByKernel .jp-starIcon { +.jp-NewLauncher-table .jp-starIcon { width: var(--jp-icon-size); height: var(--jp-icon-size); } +.jp-NewLauncher-table-scroller { + overflow: auto; + margin-top: 4px; +} + .jp-starIconButton { --jp-transition-transform: rotate(72deg);