diff --git a/src/components/table.tsx b/src/components/table.tsx index b304925..bd8c2d5 100644 --- a/src/components/table.tsx +++ b/src/components/table.tsx @@ -2,13 +2,20 @@ // Distributed under the terms of the Modified BSD License. import type { CommandRegistry } from '@lumino/commands'; import { ReadonlyJSONObject } from '@lumino/coreutils'; +import type { ISignal } from '@lumino/signaling'; import { Time } from '@jupyterlab/coreutils'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { TranslationBundle } from '@jupyterlab/translation'; import { FilterBox, UseSignal, MenuSvg } from '@jupyterlab/ui-components'; import { Table } from './base-table'; import * as React from 'react'; -import { ISettingsLayout, CommandIDs, IKernelItem } from '../types'; +import { + ISettingsLayout, + IFavoritesDatabase, + ILastUsedDatabase, + CommandIDs, + IKernelItem +} from '../types'; import { starIcon } from '../icons'; const STAR_BUTTON_CLASS = 'jp-starIconButton'; @@ -78,6 +85,8 @@ export function KernelTable(props: { onClick: (item: IKernelItem) => void; hideColumns?: string[]; showWidgetType?: boolean; + favouritesChanged: ISignal; + lastUsedChanged: ISignal; }) { const { trans } = props; let query: string; @@ -94,6 +103,19 @@ export function KernelTable(props: { // Hoisted to avoid "Rendered fewer hooks than expected" error on toggling the Star column const [, forceUpdate] = React.useReducer(x => x + 1, 0); + React.useEffect(() => { + props.favouritesChanged.connect(forceUpdate); + return () => { + props.favouritesChanged.disconnect(forceUpdate); + }; + }); + React.useEffect(() => { + props.lastUsedChanged.connect(forceUpdate); + return () => { + props.lastUsedChanged.disconnect(forceUpdate); + }; + }); + const metadataAvailable = new Set(); for (const item of props.items) { const kernelMetadata = item.metadata?.kernel; @@ -263,10 +285,9 @@ export function KernelTable(props: { : STAR_BUTTON_CLASS } title={title} - onClick={event => { - row.toggleStar(); - forceUpdate(); + onClick={async event => { event.stopPropagation(); + await row.toggleStar(); }} > diff --git a/src/database.ts b/src/database.ts index 0bd696b..442b0d9 100644 --- a/src/database.ts +++ b/src/database.ts @@ -79,8 +79,19 @@ export class LastUsedDatabase } async recordAsUsedNow(item: ILauncher.IItemOptions) { - this._set(item, new Date().toUTCString()); + await this.recordAsUsed(item, new Date()); } + + async recordAsUsed(item: ILauncher.IItemOptions, date: Date) { + await this._set(item, date.toUTCString()); + this._changed.emit(); + } + + get changed() { + return this._changed; + } + + private _changed = new Signal(this); } export class FavoritesDatabase @@ -94,7 +105,7 @@ export class FavoritesDatabase } async set(item: ILauncher.IItemOptions, isFavourite: boolean) { - this._set(item, isFavourite); + await this._set(item, isFavourite); this._changed.emit(); } diff --git a/src/dialogs.tsx b/src/dialogs.tsx index 2dd57cf..087105a 100644 --- a/src/dialogs.tsx +++ b/src/dialogs.tsx @@ -86,7 +86,8 @@ class CustomSessionContextDialogs extends SessionContextDialogs { trans, acceptDialog: () => { dialog.resolve(1); - } + }, + type: sessionContext.type }), buttons, checkbox: hasCheckbox @@ -204,6 +205,11 @@ export class KernelSelector extends ReactWidget { protected render(): React.ReactElement | null { const items: ILauncher.IItemOptions[] = []; const specs = this.options.data.specs!.kernelspecs!; + // Note: this command is not executed, but it is only used to match favourite/last used metadata + const command = + this.options.type === 'console' + ? 'console:create' + : 'notebook:create-new'; for (const spec of Object.values(specs)) { if (!spec) { @@ -212,11 +218,17 @@ export class KernelSelector extends ReactWidget { const kernelIconUrl = spec.resources['logo-svg'] || spec.resources['logo-64x64']; items.push({ - command: 'notebook:create-new', - args: { - isLauncher: true, - kernelName: spec.name - }, + command, + args: + this.options.type === 'console' + ? { + isLauncher: true, + kernelPreference: { name: spec.name } + } + : { + isLauncher: true, + kernelName: spec.name + }, kernelIconUrl, metadata: { kernel: JSONExt.deepCopy(spec.metadata || {}) as ReadonlyJSONValue @@ -233,11 +245,17 @@ export class KernelSelector extends ReactWidget { const kernelIconUrl = spec.resources['logo-svg'] || spec.resources['logo-64x64']; runningItems.push({ - command: 'notebook:create-new', - args: { - isLauncher: true, - kernelName: spec.name - }, + command, + args: + this.options.type === 'console' + ? { + isLauncher: true, + kernelPreference: { name: spec.name } + } + : { + isLauncher: true, + kernelName: spec.name + }, kernelIconUrl, metadata: { kernel: { @@ -268,6 +286,8 @@ export class KernelSelector extends ReactWidget { this._selection = item; this.options.acceptDialog(); }} + favouritesChanged={this._favoritesDatabase.changed} + lastUsedChanged={this._lastUsedDatabase.changed} /> {runningKernelsItems.length > 0 ? ( <> @@ -286,6 +306,8 @@ export class KernelSelector extends ReactWidget { this.options.acceptDialog(); }} hideColumns={['last-used', 'star']} + favouritesChanged={this._favoritesDatabase.changed} + lastUsedChanged={this._lastUsedDatabase.changed} /> ) : null} @@ -297,9 +319,13 @@ export class KernelSelector extends ReactWidget { if (!this._selection) { return null; } + // Update last used date + this._selection.markAsUsedNow().catch(console.warn); + // User selected an existing kernel if (this._selection.metadata?.model) { return this._selection.metadata.model as unknown as Kernel.IModel; } + // User starts a new kernel return { name: this._selection.args!.kernelName as string }; } @@ -321,5 +347,7 @@ export namespace KernelSelector { data: SessionContext.IKernelSearch; acceptDialog: () => void; name: string; + // known values are "notebook" and "console" + type: string; } } diff --git a/src/index.ts b/src/index.ts index a936d4e..69db821 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,40 @@ function activate( addCommands(app, trans, settings); }); + // Detect kernels started outside of the launcher, e.g. automatically due to having notebooks open from previous session + const lastSessionActivity: Record = {}; + const recordSessionActivity = async () => { + const models = [...app.serviceManager.sessions.running()]; + for (const model of models) { + if (!model.kernel) { + continue; + } + let command: string; + if (model.type === 'notebook') { + command = 'notebook:create-new'; + } else if (model.type === 'console') { + command = 'console:create'; + } else { + command = 'unknown'; + } + const key = command + '-' + model.kernel.name; + const activity = model.kernel.last_activity; + if (activity && lastSessionActivity[key] !== activity) { + lastSessionActivity[key] = activity; + const item = { + command, + args: { + isLauncher: true, + kernelName: model.kernel.name + } + }; + await database.lastUsed.recordAsUsed(item, new Date(activity)); + } + } + }; + app.serviceManager.sessions.ready.then(recordSessionActivity); + app.serviceManager.sessions.runningChanged.connect(recordSessionActivity); + commands.addCommand(CommandIDs.create, { label: trans.__('New Launcher'), icon: args => (args.toolbar ? addIcon : undefined), @@ -87,6 +121,7 @@ function activate( const settings = await settingRegistry.load(MAIN_PLUGIN_ID); await Promise.all([database.lastUsed.ready, database.favorites.ready]); + const launcher = new Launcher({ model, cwd, diff --git a/src/item.ts b/src/item.ts index 09f83be..4b391ef 100644 --- a/src/item.ts +++ b/src/item.ts @@ -21,7 +21,6 @@ export class Item implements IItem { caption: string; icon: VirtualElement.IRenderer | undefined; iconClass: string; - starred: boolean; constructor( private _options: { @@ -32,8 +31,7 @@ export class Item implements IItem { favoritesDatabase: IFavoritesDatabase; } ) { - const { item, commands, lastUsedDatabase, favoritesDatabase, cwd } = - _options; + const { item, commands, cwd } = _options; const args = { ...item.args, cwd }; // base this.command = item.command; @@ -47,8 +45,6 @@ export class Item implements IItem { 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; // special handling for conda-store // https://www.nebari.dev/docs/faq/#why-is-there-duplication-in-names-of-environments const kernel = this.metadata['kernel'] as JSONObject | undefined; @@ -79,12 +75,18 @@ export class Item implements IItem { this.icon = codeServerIcon; } } + get starred() { + const { item, favoritesDatabase } = this._options; + return favoritesDatabase.get(item) ?? false; + } get lastUsed(): Date | null { - return this._lastUsed; + const value = this._lastUsed; + this._setRefreshClock(value); + return value; } - set lastUsed(value: Date | null) { - this._lastUsed = value; - this._setRefreshClock(); + private get _lastUsed(): Date | null { + const { item, lastUsedDatabase } = this._options; + return lastUsedDatabase.get(item); } get refreshLastUsed(): ISignal { return this._refreshLastUsed; @@ -93,24 +95,20 @@ export class Item implements IItem { 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(); } - async markAsUsed() { + async markAsUsedNow() { const { item, lastUsedDatabase } = this._options; await lastUsedDatabase.recordAsUsedNow(item); - this.lastUsed = lastUsedDatabase.get(item); this._refreshLastUsed.emit(); } - toggleStar() { + async 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; + private _setRefreshClock(value: Date | null) { if (this._refreshClock !== null) { window.clearTimeout(this._refreshClock); this._refreshClock = null; @@ -132,10 +130,9 @@ export class Item implements IItem { : 60 * minute; this._refreshClock = window.setTimeout(() => { this._refreshLastUsed.emit(); - this._setRefreshClock(); + this._setRefreshClock(this._lastUsed); }, interval); } private _refreshLastUsed = new Signal(this); private _refreshClock: number | null = null; - private _lastUsed: Date | null = null; } diff --git a/src/launcher.tsx b/src/launcher.tsx index 2e6dac0..f334c8c 100644 --- a/src/launcher.tsx +++ b/src/launcher.tsx @@ -33,7 +33,8 @@ function LauncherBody(props: { otherItems: IItem[]; commands: CommandRegistry; settings: ISettingRegistry.ISettings; - favouritesChanged?: ISignal; + favouritesChanged: ISignal; + lastUsedChanged: ISignal; }): React.ReactElement { const { trans, cwd, typeItems, otherItems, favouritesChanged } = props; const [query, updateQuery] = React.useState(''); @@ -162,6 +163,8 @@ function LauncherBody(props: { settings={props.settings} trans={trans} onClick={item => item.execute()} + favouritesChanged={props.favouritesChanged} + lastUsedChanged={props.lastUsedChanged} /> ) : ( 'No starred items' @@ -182,6 +185,8 @@ function LauncherBody(props: { settings={props.settings} trans={trans} onClick={item => item.execute()} + favouritesChanged={props.favouritesChanged} + lastUsedChanged={props.lastUsedChanged} /> item.execute()} + favouritesChanged={props.favouritesChanged} + lastUsedChanged={props.lastUsedChanged} /> @@ -326,6 +333,7 @@ export class NewLauncher extends Launcher { otherItems={otherItems} settings={this._settings} favouritesChanged={this._favoritesDatabase.changed} + lastUsedChanged={this._lastUsedDatabase.changed} /> ); } diff --git a/src/types.ts b/src/types.ts index 8271c91..a8078c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,8 +33,9 @@ export interface IItem extends ILauncher.IItemOptions { execute: () => Promise; lastUsed: Date | null; starred: boolean; - toggleStar: () => void; + toggleStar: () => Promise; refreshLastUsed: ISignal; + markAsUsedNow: () => Promise; } export interface IKernelItem extends IItem { @@ -44,7 +45,9 @@ export interface IKernelItem extends IItem { export interface ILastUsedDatabase { ready: Promise; get(item: ILauncher.IItemOptions): Date | null; + recordAsUsed(item: ILauncher.IItemOptions, date: Date): Promise; recordAsUsedNow(item: ILauncher.IItemOptions): Promise; + changed: ISignal; } export interface IFavoritesDatabase {