From 48fabfdf8a077397363f13612d9c7684810c75ba Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:21:10 +0000 Subject: [PATCH 01/34] Bring jupyterlab-recents into the core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Frédéric Collonval Co-authored-by: Shreyas Cholia Co-authored-by: Matt Henderson Co-authored-by: Trevor Slaton Co-authored-by: Adrien Delsalle --- packages/docmanager-extension/package.json | 1 + .../docmanager-extension/schema/plugin.json | 7 + packages/docmanager-extension/src/index.tsx | 19 +- packages/docmanager-extension/src/recents.ts | 73 +++++ packages/docmanager-extension/tsconfig.json | 3 + packages/docmanager/package.json | 1 + packages/docmanager/src/index.ts | 1 + packages/docmanager/src/manager.ts | 14 +- packages/docmanager/src/recents.ts | 279 ++++++++++++++++++ packages/docmanager/src/tokens.ts | 58 ++++ packages/docmanager/src/widgetmanager.ts | 37 +++ packages/docmanager/tsconfig.json | 3 + packages/docmanager/tsconfig.test.json | 3 + packages/filebrowser-extension/src/index.ts | 9 +- packages/filebrowser/src/tokens.ts | 12 +- packages/mainmenu-extension/package.json | 3 + packages/mainmenu-extension/src/index.ts | 3 +- packages/mainmenu-extension/src/recents.ts | 183 ++++++++++++ packages/mainmenu-extension/style/index.css | 2 + packages/mainmenu-extension/style/index.js | 2 + packages/mainmenu-extension/tsconfig.json | 6 + yarn.lock | 5 + 22 files changed, 713 insertions(+), 11 deletions(-) create mode 100644 packages/docmanager-extension/src/recents.ts create mode 100644 packages/docmanager/src/recents.ts create mode 100644 packages/mainmenu-extension/src/recents.ts diff --git a/packages/docmanager-extension/package.json b/packages/docmanager-extension/package.json index 00603b6b45f2..62ffcde336e6 100644 --- a/packages/docmanager-extension/package.json +++ b/packages/docmanager-extension/package.json @@ -45,6 +45,7 @@ "@jupyterlab/docregistry": "^4.1.0-alpha.4", "@jupyterlab/services": "^7.1.0-alpha.4", "@jupyterlab/settingregistry": "^4.1.0-alpha.4", + "@jupyterlab/statedb": "^4.1.0-alpha.4", "@jupyterlab/statusbar": "^4.1.0-alpha.4", "@jupyterlab/translation": "^4.1.0-alpha.4", "@jupyterlab/ui-components": "^4.1.0-alpha.4", diff --git a/packages/docmanager-extension/schema/plugin.json b/packages/docmanager-extension/schema/plugin.json index eb80613446cd..331fccd87414 100644 --- a/packages/docmanager-extension/schema/plugin.json +++ b/packages/docmanager-extension/schema/plugin.json @@ -149,6 +149,13 @@ "title": "Rename Untitled File On First Save", "description": "Whether to prompt to rename untitled file on first manual save.", "default": true + }, + "maximalRecents": { + "type": "number", + "title": "Recent Items Number", + "description": "Number of recently opened/closed files and directories to remember.", + "default": 10, + "minimum": 0 } }, "additionalProperties": false, diff --git a/packages/docmanager-extension/src/index.tsx b/packages/docmanager-extension/src/index.tsx index d480deb75e87..f5ff0c49650e 100644 --- a/packages/docmanager-extension/src/index.tsx +++ b/packages/docmanager-extension/src/index.tsx @@ -31,6 +31,7 @@ import { DocumentManager, IDocumentManager, IDocumentWidgetOpener, + IRecentsManager, PathStatus, renameDialog, SavingStatus @@ -52,6 +53,7 @@ import { IDisposable } from '@lumino/disposable'; import { ISignal, Signal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; import * as React from 'react'; +import { recentsManagerPlugin } from './recents'; /** * The command IDs used by the document manager plugin. @@ -168,14 +170,21 @@ const manager: JupyterFrontEndPlugin = { description: 'Provides the document manager.', provides: IDocumentManager, requires: [IDocumentWidgetOpener], - optional: [ITranslator, ILabStatus, ISessionContextDialogs, JupyterLab.IInfo], + optional: [ + ITranslator, + ILabStatus, + ISessionContextDialogs, + JupyterLab.IInfo, + IRecentsManager + ], activate: ( app: JupyterFrontEnd, widgetOpener: IDocumentWidgetOpener, translator_: ITranslator | null, status: ILabStatus | null, sessionDialogs_: ISessionContextDialogs | null, - info: JupyterLab.IInfo | null + info: JupyterLab.IInfo | null, + recentsManager: IRecentsManager | null ) => { const { serviceManager: manager, docRegistry: registry } = app; const translator = translator_ ?? nullTranslator; @@ -196,7 +205,8 @@ const manager: JupyterFrontEndPlugin = { return info.isConnected; } return true; - } + }, + recentsManager: recentsManager ?? undefined }); return docManager; @@ -556,7 +566,8 @@ const plugins: JupyterFrontEndPlugin[] = [ savingStatusPlugin, downloadPlugin, openBrowserTabPlugin, - openerPlugin + openerPlugin, + recentsManagerPlugin ]; export default plugins; diff --git a/packages/docmanager-extension/src/recents.ts b/packages/docmanager-extension/src/recents.ts new file mode 100644 index 000000000000..12d1bb2bfc83 --- /dev/null +++ b/packages/docmanager-extension/src/recents.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { IRecentsManager, RecentsManager } from '@jupyterlab/docmanager'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { IStateDB } from '@jupyterlab/statedb'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; + +/** + * A namespace for command IDs. + */ +namespace CommandIDs { + export const clearRecents = 'docmanager:clear-recents'; +} + +namespace PluginIDs { + export const recentsManager = '@jupyterlab/docmanager-extension:recents'; + export const mainPlugin = '@jupyterlab/docmanager-extension:plugin'; +} + +export const recentsManagerPlugin: JupyterFrontEndPlugin = { + id: PluginIDs.recentsManager, + autoStart: true, + requires: [IStateDB], + optional: [ISettingRegistry, ITranslator], + provides: IRecentsManager, + activate: ( + app: JupyterFrontEnd, + stateDB: IStateDB, + settingRegistry: ISettingRegistry | null, + translator: ITranslator | null + ): IRecentsManager => { + const { serviceManager } = app; + const trans = (translator ?? nullTranslator).load('jupyterlab'); + + // Create the manager + const recentsManager = new RecentsManager({ + stateDB: stateDB, + contents: serviceManager.contents + }); + + const updateSettings = (settings: ISettingRegistry.ISettings) => { + recentsManager.maximalRecentsLength = settings.get('maximalRecents') + .composite as number; + }; + + if (settingRegistry) { + void Promise.all([ + app.restored, + settingRegistry.load(PluginIDs.mainPlugin) + ]).then(([_, settings]) => { + settings.changed.connect(updateSettings); + updateSettings(settings); + }); + } + + app.commands.addCommand(CommandIDs.clearRecents, { + execute: () => { + recentsManager.clearRecents(); + }, + label: trans.__('Clear Recently Opened'), + caption: trans.__('Clear the list of recently opened items.') + }); + + return recentsManager; + } +}; diff --git a/packages/docmanager-extension/tsconfig.json b/packages/docmanager-extension/tsconfig.json index 7e7f847ba406..da51c9f3396d 100644 --- a/packages/docmanager-extension/tsconfig.json +++ b/packages/docmanager-extension/tsconfig.json @@ -27,6 +27,9 @@ { "path": "../settingregistry" }, + { + "path": "../statedb" + }, { "path": "../statusbar" }, diff --git a/packages/docmanager/package.json b/packages/docmanager/package.json index d42df57ea47d..089f6edfae59 100644 --- a/packages/docmanager/package.json +++ b/packages/docmanager/package.json @@ -46,6 +46,7 @@ "@jupyterlab/coreutils": "^6.1.0-alpha.4", "@jupyterlab/docregistry": "^4.1.0-alpha.4", "@jupyterlab/services": "^7.1.0-alpha.4", + "@jupyterlab/statedb": "^4.1.0-alpha.4", "@jupyterlab/statusbar": "^4.1.0-alpha.4", "@jupyterlab/translation": "^4.1.0-alpha.4", "@jupyterlab/ui-components": "^4.1.0-alpha.4", diff --git a/packages/docmanager/src/index.ts b/packages/docmanager/src/index.ts index 8c26e312b905..47ab96a28850 100644 --- a/packages/docmanager/src/index.ts +++ b/packages/docmanager/src/index.ts @@ -12,3 +12,4 @@ export * from './savehandler'; export * from './savingstatus'; export * from './tokens'; export * from './widgetmanager'; +export * from './recents'; diff --git a/packages/docmanager/src/manager.ts b/packages/docmanager/src/manager.ts index bd7bcc8a56ee..d41247f6617c 100644 --- a/packages/docmanager/src/manager.ts +++ b/packages/docmanager/src/manager.ts @@ -17,7 +17,11 @@ import { AttachedProperty } from '@lumino/properties'; import { ISignal, Signal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; import { SaveHandler } from './savehandler'; -import { IDocumentManager, IDocumentWidgetOpener } from './tokens'; +import { + IDocumentManager, + IDocumentWidgetOpener, + IRecentsManager +} from './tokens'; import { DocumentWidgetManager } from './widgetmanager'; /** @@ -48,7 +52,8 @@ export class DocumentManager implements IDocumentManager { const widgetManager = new DocumentWidgetManager({ registry: this.registry, - translator: this.translator + translator: this.translator, + recentsManager: options.recentsManager }); widgetManager.activateRequested.connect(this._onActivateRequested, this); widgetManager.stateChanged.connect(this._onWidgetStateChanged, this); @@ -766,6 +771,11 @@ export namespace DocumentManager { * By default, it always returns `true`. */ isConnectedCallback?: () => boolean; + + /** + * The manager for recent documents. + */ + recentsManager?: IRecentsManager; } } diff --git a/packages/docmanager/src/recents.ts b/packages/docmanager/src/recents.ts new file mode 100644 index 000000000000..7878daf8c65f --- /dev/null +++ b/packages/docmanager/src/recents.ts @@ -0,0 +1,279 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { PageConfig } from '@jupyterlab/coreutils'; +import { Contents, ServerConnection } from '@jupyterlab/services'; +import { IStateDB } from '@jupyterlab/statedb'; +import { IDisposable } from '@lumino/disposable'; +import { ISignal, Signal } from '@lumino/signaling'; +import { IRecentsManager, RecentDocument } from './tokens'; + +type RecentsDatabase = { + [key: string]: RecentDocument[]; + opened: RecentDocument[]; + closed: RecentDocument[]; +}; + +export class RecentsManager implements IRecentsManager, IDisposable { + constructor(options: RecentsManager.IOptions) { + this._serverRoot = PageConfig.getOption('serverRoot'); + this._stateDB = options.stateDB; + this._contentsManager = options.contents; + + this._loadRecents().catch(r => { + console.error(`Failed to load recent list from state:\n${r}`); + }); + } + + /** + * Whether the manager is disposed or not. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * List of recently opened items + */ + get recentlyOpened(): RecentDocument[] { + const recents = this._recents.opened || []; + return recents.filter(r => r.root === this._serverRoot); + } + + /** + * List of recently opened items + */ + get recentlyClosed(): RecentDocument[] { + const recents = this._recents.closed || []; + return recents.filter(r => r.root === this._serverRoot); + } + + /** + * Signal emitted when the recent list changes. + */ + get changed(): ISignal { + return this._recentsChanged; + } + + /** + * Maximal number of recent items to list. + */ + get maximalRecentsLength(): number { + return this._maxRecentsLength; + } + set maximalRecentsLength(value: number) { + this._maxRecentsLength = Math.round(Math.max(1, value)); + let changed = false; + for (const type of ['opened', 'closed']) { + if (this._recents[type].length > this._maxRecentsLength) { + this._recents[type].length = this._maxRecentsLength; + changed = true; + } + } + if (changed) { + this._recentsChanged.emit(undefined); + } + } + + /** + * Dispose recent manager resources + */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._isDisposed = true; + Signal.clearData(this); + } + + /** + * Add a new path to the recent list. + * + * @param path Path + * @param contentType Content type + */ + addRecent( + path: string, + contentType: string, + type: 'opened' | 'closed' + ): void { + const recent: RecentDocument = { + root: this._serverRoot, + path, + contentType + }; + const recents = this._recents[type]; + // Check if it's already present; if so remove it + const existingIndex = recents.findIndex(r => r.path === path); + if (existingIndex >= 0) { + recents.splice(existingIndex, 1); + } + // Add to the front of the list + recents.unshift(recent); + + this._setRecents(recents, type); + this._recentsChanged.emit(undefined); + } + + /** + * Clear the recents list + */ + clearRecents(): void { + this._setRecents([], 'opened'); + this._setRecents([], 'closed'); + this._recentsChanged.emit(undefined); + } + + /** + * Remove a path from both lists (opened and closed) + * + * @param path Path to remove + */ + private _removeRecent(path: string): void { + let changed = false; + for (const type of ['opened', 'closed']) { + const recents = this._recents[type]; + const newRecents = recents.filter(r => path === r.path); + if (recents.length !== newRecents.length) { + this._setRecents(newRecents, type as 'opened' | 'closed'); + } + } + if (changed) { + this._recentsChanged.emit(undefined); + } + } + + /** + * Check if the recent item is valid, remove if it from both lists if it is not. + */ + async validate(recent: RecentDocument): Promise { + const valid = await this._isValid(recent); + if (!valid) { + this._removeRecent(recent.path); + } + return valid; + } + + private async _isValid(recent: RecentDocument): Promise { + try { + await this._contentsManager.get(recent.path, { content: false }); + } catch (e) { + if ((e as ServerConnection.ResponseError).response?.status === 404) { + return false; + } + } + return true; + } + + /** + * Set the recent list + * @param recents The new recent list + */ + private _setRecents( + recents: RecentDocument[], + type: 'opened' | 'closed' + ): void { + this._recents[type] = recents + .slice(0, this.maximalRecentsLength) + .sort((a, b) => { + if (a.root === b.root) { + return 0; + } else { + return a.root !== this._serverRoot ? 1 : -1; + } + }); + this.saveRecents(); + } + + /** + * Load the recent items from the state. + */ + private async _loadRecents(): Promise { + const recents = ((await this._stateDB.fetch( + Private.stateDBKey + )) as RecentsDatabase) || { + opened: [], + closed: [] + }; + const allRecents = [...recents.opened, ...recents.closed]; + const invalidPaths = new Set(await this._getInvalidPaths(allRecents)); + + for (const type of ['opened', 'closed']) { + this._setRecents( + recents[type].filter(r => !invalidPaths.has(r.path)), + type as 'opened' | 'closed' + ); + } + } + + /** + * Get the list of invalid path in recents. + */ + private async _getInvalidPaths(recents: RecentDocument[]): Promise { + const invalidPathsOrNulls = await Promise.all( + recents.map(async r => { + if (await this._isValid(r)) { + return null; + } else { + return r.path; + } + }) + ); + return invalidPathsOrNulls.filter(x => typeof x === 'string') as string[]; + } + /** + * Save the recent items to the state. + */ + protected saveRecents(): void { + clearTimeout(this._saveRoutine); + // Save _recents 500 ms after the last time saveRecents has been called + this._saveRoutine = setTimeout(async () => { + // If there's a previous request pending, wait 500 ms and try again + if (this._awaitingSaveCompletion) { + this.saveRecents(); + } else { + this._awaitingSaveCompletion = true; + try { + await this._stateDB.save(Private.stateDBKey, this._recents); + this._awaitingSaveCompletion = false; + } catch (e) { + this._awaitingSaveCompletion = false; + console.log('Saving recents failed'); + // Try again + this.saveRecents(); + } + } + }, 500); + } + + private _recentsChanged = new Signal(this); + private _serverRoot: string; + private _stateDB: IStateDB; + private _contentsManager: Contents.IManager; + private _recents: RecentsDatabase = { + opened: [], + closed: [] + }; + // Will store a Timemout call that saves recents changes after a delay + private _saveRoutine: ReturnType | undefined; + // Whether there are local changes sent to be recorded without verification + private _awaitingSaveCompletion = false; + + private _isDisposed = false; + + private _maxRecentsLength = 10; +} + +export namespace RecentsManager { + export interface IOptions { + stateDB: IStateDB; + contents: Contents.IManager; + } +} + +namespace Private { + export const stateDBKey = 'docmanager:recents'; + export const poolKey = 'docmanager:recents'; +} diff --git a/packages/docmanager/src/tokens.ts b/packages/docmanager/src/tokens.ts index b858dfbd9209..64f46ef2a258 100644 --- a/packages/docmanager/src/tokens.ts +++ b/packages/docmanager/src/tokens.ts @@ -27,6 +27,14 @@ export const IDocumentWidgetOpener = new Token( `A service to open a widget.` ); +/** + * The recent documents database token. + */ +export const IRecentsManager = new Token( + '@jupyterlab/docmanager:IRecentsManager', + `A service providing information about recently opened and closed documents` +); + /** * The interface for a document manager. */ @@ -279,3 +287,53 @@ export interface IDocumentWidgetOpener { */ readonly opened: ISignal; } + +/** + * Recent opened items manager. + */ +export interface IRecentsManager { + /** + * Get the recently opened documents. + */ + readonly recentlyOpened: RecentDocument[]; + + /** + * Get the recently closed items. + */ + readonly recentlyClosed: RecentDocument[]; + + /** + * Signal emitted when either of the list changes. + */ + readonly changed: ISignal; + + /** + * Check if the recent item is valid, remove if it from both lists if it is not. + */ + validate(recent: RecentDocument): Promise; + + /** + * Add a new path to the recent list. + */ + addRecent(path: string, contentType: string, type: 'opened' | 'closed'): void; +} + +/** + * The interface for a recent document. + */ +export type RecentDocument = { + /** + * The server root path. + * + * Allows to select only the currently accessible documents. + */ + root: string; + /** + * The path to the document. + */ + path: string; + /** + * The document content type or `directory` literal for directories. + */ + contentType: string; +}; diff --git a/packages/docmanager/src/widgetmanager.ts b/packages/docmanager/src/widgetmanager.ts index dad44890c105..06846c694157 100644 --- a/packages/docmanager/src/widgetmanager.ts +++ b/packages/docmanager/src/widgetmanager.ts @@ -12,6 +12,7 @@ import { IMessageHandler, Message, MessageLoop } from '@lumino/messaging'; import { AttachedProperty } from '@lumino/properties'; import { ISignal, Signal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; +import { IRecentsManager } from './tokens'; /** * The class name added to document widgets. @@ -28,6 +29,7 @@ export class DocumentWidgetManager implements IDisposable { constructor(options: DocumentWidgetManager.IOptions) { this._registry = options.registry; this.translator = options.translator || nullTranslator; + this._recentsManager = options.recentsManager || null; } /** @@ -259,6 +261,14 @@ export class DocumentWidgetManager implements IDisposable { case 'activate-request': { const context = this.contextForWidget(handler as Widget); if (context) { + context.ready + .then(() => { + // contentsModel is null until the the context is ready + this._recordAsRecent(context.contentsModel!); + }) + .catch(() => { + console.warn('Could not record the recents status for', context); + }); this._activateRequested.emit(context.path); } break; @@ -355,6 +365,27 @@ export class DocumentWidgetManager implements IDisposable { return Promise.resolve(void 0); } + /** + * Record the activated file, and its parent directory, as recent + */ + private _recordAsRecent(model: Omit) { + const recents = this._recentsManager; + if (!recents) { + // no-op + return; + } + const path = model.path; + const fileType = this._registry.getFileTypeForModel(model); + const contentType = fileType.contentType; + recents.addRecent(path, contentType, 'opened'); + // Add the containing directory, too + if (contentType !== 'directory') { + const parent = + path.lastIndexOf('/') > 0 ? path.slice(0, path.lastIndexOf('/')) : ''; + recents.addRecent(parent, 'directory', 'opened'); + } + } + /** * Ask the user whether to close an unsaved file. */ @@ -513,6 +544,7 @@ export class DocumentWidgetManager implements IDisposable { private _stateChanged = new Signal>( this ); + private _recentsManager: IRecentsManager | null; } /** @@ -528,6 +560,11 @@ export namespace DocumentWidgetManager { */ registry: DocumentRegistry; + /** + * The manager for recent documents. + */ + recentsManager?: IRecentsManager; + /** * The application language translator. */ diff --git a/packages/docmanager/tsconfig.json b/packages/docmanager/tsconfig.json index c3879c521eee..88c203dea37e 100644 --- a/packages/docmanager/tsconfig.json +++ b/packages/docmanager/tsconfig.json @@ -18,6 +18,9 @@ { "path": "../services" }, + { + "path": "../statedb" + }, { "path": "../statusbar" }, diff --git a/packages/docmanager/tsconfig.test.json b/packages/docmanager/tsconfig.test.json index eccbf41a7ceb..0cb4a5db3c62 100644 --- a/packages/docmanager/tsconfig.test.json +++ b/packages/docmanager/tsconfig.test.json @@ -14,6 +14,9 @@ { "path": "../services" }, + { + "path": "../statedb" + }, { "path": "../statusbar" }, diff --git a/packages/filebrowser-extension/src/index.ts b/packages/filebrowser-extension/src/index.ts index 0c359ae42068..1ebe07b9caf0 100644 --- a/packages/filebrowser-extension/src/index.ts +++ b/packages/filebrowser-extension/src/index.ts @@ -157,7 +157,7 @@ const namespace = 'filebrowser'; /** * The default file browser extension. */ -const browser: JupyterFrontEndPlugin = { +const browser: JupyterFrontEndPlugin = { id: FILE_BROWSER_PLUGIN_ID, description: 'Set up the default file browser (commands, settings,...).', requires: [IDefaultFileBrowser, IFileBrowserFactory, ITranslator], @@ -178,7 +178,7 @@ const browser: JupyterFrontEndPlugin = { settingRegistry: ISettingRegistry | null, treePathUpdater: ITreePathUpdater | null, commandPalette: ICommandPalette | null - ): Promise => { + ): Promise => { const browser = defaultFileBrowser; // Let the application restorer track the primary file browser (that is @@ -206,7 +206,7 @@ const browser: JupyterFrontEndPlugin = { commandPalette ); - return void Promise.all([app.restored, browser.model.restored]).then(() => { + return Promise.all([app.restored, browser.model.restored]).then(() => { if (treePathUpdater) { browser.model.pathChanged.connect((sender, args) => { treePathUpdater(args.newValue); @@ -250,6 +250,9 @@ const browser: JupyterFrontEndPlugin = { onSettingsChanged(settings); }); } + return { + openPath: CommandIDs.openPath + }; }); } }; diff --git a/packages/filebrowser/src/tokens.ts b/packages/filebrowser/src/tokens.ts index 205c67a56de8..bf371f158be9 100644 --- a/packages/filebrowser/src/tokens.ts +++ b/packages/filebrowser/src/tokens.ts @@ -121,7 +121,17 @@ export namespace IFileBrowserFactory { /** * The token that indicates the default file browser commands are loaded. */ -export const IFileBrowserCommands = new Token( +export const IFileBrowserCommands = new Token( '@jupyterlab/filebrowser:IFileBrowserCommands', 'A token to ensure file browser commands are loaded.' ); + +/** + * The identifiers of loaded commands exposed for reuse. + */ +export interface IFileBrowserCommands { + /** + * Command for opening a file or a directory by path. + */ + openPath: string; +} diff --git a/packages/mainmenu-extension/package.json b/packages/mainmenu-extension/package.json index 97bb484a89bf..082ea9bb42dc 100644 --- a/packages/mainmenu-extension/package.json +++ b/packages/mainmenu-extension/package.json @@ -41,6 +41,8 @@ "@jupyterlab/application": "^4.1.0-alpha.4", "@jupyterlab/apputils": "^4.2.0-alpha.4", "@jupyterlab/coreutils": "^6.1.0-alpha.4", + "@jupyterlab/docmanager": "^4.1.0-alpha.4", + "@jupyterlab/filebrowser": "^4.1.0-alpha.4", "@jupyterlab/mainmenu": "^4.1.0-alpha.4", "@jupyterlab/services": "^7.1.0-alpha.4", "@jupyterlab/settingregistry": "^4.1.0-alpha.4", @@ -49,6 +51,7 @@ "@lumino/algorithm": "^2.0.1", "@lumino/coreutils": "^2.1.2", "@lumino/disposable": "^2.1.2", + "@lumino/messaging": "^2.0.1", "@lumino/widgets": "^2.3.1" }, "devDependencies": { diff --git a/packages/mainmenu-extension/src/index.ts b/packages/mainmenu-extension/src/index.ts index 6f073f2f9dd2..1a5d8246d860 100644 --- a/packages/mainmenu-extension/src/index.ts +++ b/packages/mainmenu-extension/src/index.ts @@ -44,6 +44,7 @@ import { find } from '@lumino/algorithm'; import { JSONExt } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; import { Menu, Widget } from '@lumino/widgets'; +import { recentsMenuPlugin } from './recents'; const PLUGIN_ID = '@jupyterlab/mainmenu-extension:plugin'; @@ -759,7 +760,7 @@ function createHelpMenu( ); } -export default plugin; +export default [plugin, recentsMenuPlugin]; /** * A namespace for Private data. diff --git a/packages/mainmenu-extension/src/recents.ts b/packages/mainmenu-extension/src/recents.ts new file mode 100644 index 000000000000..18e9759f73e1 --- /dev/null +++ b/packages/mainmenu-extension/src/recents.ts @@ -0,0 +1,183 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { PathExt } from '@jupyterlab/coreutils'; +import { showErrorMessage } from '@jupyterlab/apputils'; +import { IRecentsManager, RecentDocument } from '@jupyterlab/docmanager'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { IFileBrowserCommands } from '@jupyterlab/filebrowser'; +import { IMainMenu } from '@jupyterlab/mainmenu'; +import { Menu } from '@lumino/widgets'; +import { PromiseDelegate } from '@lumino/coreutils'; +import { Message } from '@lumino/messaging'; + +const PLUGIN_ID = '@jupyterlab/mainmenu-extension:recents'; + +/** + * A namespace for command IDs. + */ +namespace CommandIDs { + /** + * Open a recent document or directory + */ + export const openRecent = 'recentmenu:open-recent'; + /** + * Clear recent documents and directories (implemented in docmanager) + */ + export const clearRecents = 'docmanager:clear-recents'; +} + +/** + * Recents submenu + * + * It will trigger validation onBeforeAttach. + */ +class RecentsMenu extends Menu { + constructor( + options: Menu.IOptions & { + manager: IRecentsManager; + showDirectories: boolean; + } + ) { + super(options); + this._manager = options.manager; + this._showDirectories = options.showDirectories; + this._manager.changed.connect(this.updateItems, this); + } + + private async _validateRecentlyOpened(): Promise { + return void Promise.all( + this._manager.recentlyOpened.map(recent => this._manager.validate(recent)) + ); + } + + protected onBeforeAttach(msg: Message): void { + const timeout = new PromiseDelegate(); + setTimeout(() => { + timeout.reject('Recents validation timed out.'); + }, 550); + Promise.race([timeout.promise, this._validateRecentlyOpened()]) + .then(() => { + this.update(); + }) + .catch(() => { + // no-op + }); + super.onBeforeAttach(msg); + } + + protected updateItems(): void { + // We cannot edit the item list on the fly because it will close + // the menu - so we use `isEnabled` in the command and trigger a + // UI update to emulate that; while `isVisible` would work too, + // that could cause the user to mis-clicks when items move around + // because another item was hidden. + this.clearItems(); + let addSeparator = true; + this._manager.recentlyOpened + .sort((a: RecentDocument, b: RecentDocument) => { + if (a.contentType === b.contentType) { + return 0; + } else { + return a.contentType !== 'directory' ? 1 : -1; + } + }) + .forEach((recent: RecentDocument) => { + const isDirectory = recent.contentType === 'directory'; + if (isDirectory && !this._showDirectories) { + return; + } + if (addSeparator && !isDirectory) { + addSeparator = false; + this.addItem({ type: 'separator' }); + } + this.addItem({ + command: CommandIDs.openRecent, + args: { recent } + }); + }); + this.addItem({ type: 'separator' }); + this.addItem({ + command: CommandIDs.clearRecents + }); + } + + private _manager: IRecentsManager; + private _showDirectories: boolean; +} + +/** + * Add recent files and directories to sub-menu. + */ +export const recentsMenuPlugin: JupyterFrontEndPlugin = { + id: PLUGIN_ID, + autoStart: true, + requires: [IRecentsManager, IMainMenu], + optional: [ISettingRegistry, IFileBrowserCommands, ITranslator], + activate: ( + app: JupyterFrontEnd, + recentsManager: IRecentsManager, + mainMenu: IMainMenu, + fileBrowserCommands: IFileBrowserCommands | null, + translator: ITranslator | null + ): void => { + const { commands } = app; + const trans = (translator ?? nullTranslator).load('jupyterlab'); + + // Do not show directories if the file browser is not present + const showDirectories = fileBrowserCommands !== null; + + // Commands + commands.addCommand(CommandIDs.openRecent, { + execute: async args => { + const recent = args.recent as RecentDocument; + const path = recent.path === '' ? '/' : recent.path; + const isValid = await recentsManager.validate(recent); + if (!isValid) { + return showErrorMessage( + trans.__('Could Not Open Recent'), + trans.__( + '%1 is no longer valid and will be removed from the list', + recent.path + ) + ); + } + if (fileBrowserCommands) { + // Note: prefer file browser (if available) to allow opening directories + await commands.execute(fileBrowserCommands.openPath, { path }); + } else { + await commands.execute('docmanager:open', { path }); + } + // If path not found, validating will remove it after an error message + }, + label: args => { + const recent = args.recent as RecentDocument; + return PathExt.joinWithLeadingSlash(recent.root, recent.path); + }, + isEnabled: args => + recentsManager.recentlyOpened.includes(args.recent as RecentDocument) + }); + const submenu = new RecentsMenu({ + commands, + manager: recentsManager, + showDirectories + }); + submenu.title.label = trans.__('Open Recent'); + mainMenu.fileMenu.addGroup( + [ + { + type: 'submenu' as Menu.ItemType, + submenu + } + ], + 1 + ); + } +}; diff --git a/packages/mainmenu-extension/style/index.css b/packages/mainmenu-extension/style/index.css index b7a4e62ec72d..bb19996cfd5d 100644 --- a/packages/mainmenu-extension/style/index.css +++ b/packages/mainmenu-extension/style/index.css @@ -8,4 +8,6 @@ @import url('~@jupyterlab/ui-components/style/index.css'); @import url('~@jupyterlab/apputils/style/index.css'); @import url('~@jupyterlab/application/style/index.css'); +@import url('~@jupyterlab/docmanager/style/index.css'); +@import url('~@jupyterlab/filebrowser/style/index.css'); @import url('~@jupyterlab/mainmenu/style/index.css'); diff --git a/packages/mainmenu-extension/style/index.js b/packages/mainmenu-extension/style/index.js index 244bbd06b515..e9d3a6fdfd5f 100644 --- a/packages/mainmenu-extension/style/index.js +++ b/packages/mainmenu-extension/style/index.js @@ -8,4 +8,6 @@ import '@lumino/widgets/style/index.js'; import '@jupyterlab/ui-components/style/index.js'; import '@jupyterlab/apputils/style/index.js'; import '@jupyterlab/application/style/index.js'; +import '@jupyterlab/docmanager/style/index.js'; +import '@jupyterlab/filebrowser/style/index.js'; import '@jupyterlab/mainmenu/style/index.js'; diff --git a/packages/mainmenu-extension/tsconfig.json b/packages/mainmenu-extension/tsconfig.json index 3e1ce91d3447..0dace7c4c0c2 100644 --- a/packages/mainmenu-extension/tsconfig.json +++ b/packages/mainmenu-extension/tsconfig.json @@ -15,6 +15,12 @@ { "path": "../coreutils" }, + { + "path": "../docmanager" + }, + { + "path": "../filebrowser" + }, { "path": "../mainmenu" }, diff --git a/yarn.lock b/yarn.lock index 625211dd0c74..55fe56c047b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2861,6 +2861,7 @@ __metadata: "@jupyterlab/docregistry": ^4.1.0-alpha.4 "@jupyterlab/services": ^7.1.0-alpha.4 "@jupyterlab/settingregistry": ^4.1.0-alpha.4 + "@jupyterlab/statedb": ^4.1.0-alpha.4 "@jupyterlab/statusbar": ^4.1.0-alpha.4 "@jupyterlab/translation": ^4.1.0-alpha.4 "@jupyterlab/ui-components": ^4.1.0-alpha.4 @@ -2885,6 +2886,7 @@ __metadata: "@jupyterlab/coreutils": ^6.1.0-alpha.4 "@jupyterlab/docregistry": ^4.1.0-alpha.4 "@jupyterlab/services": ^7.1.0-alpha.4 + "@jupyterlab/statedb": ^4.1.0-alpha.4 "@jupyterlab/statusbar": ^4.1.0-alpha.4 "@jupyterlab/testing": ^4.1.0-alpha.4 "@jupyterlab/translation": ^4.1.0-alpha.4 @@ -3863,6 +3865,8 @@ __metadata: "@jupyterlab/application": ^4.1.0-alpha.4 "@jupyterlab/apputils": ^4.2.0-alpha.4 "@jupyterlab/coreutils": ^6.1.0-alpha.4 + "@jupyterlab/docmanager": ^4.1.0-alpha.4 + "@jupyterlab/filebrowser": ^4.1.0-alpha.4 "@jupyterlab/mainmenu": ^4.1.0-alpha.4 "@jupyterlab/services": ^7.1.0-alpha.4 "@jupyterlab/settingregistry": ^4.1.0-alpha.4 @@ -3871,6 +3875,7 @@ __metadata: "@lumino/algorithm": ^2.0.1 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 + "@lumino/messaging": ^2.0.1 "@lumino/widgets": ^2.3.1 rimraf: ~5.0.5 typedoc: ~0.24.7 From 329cf25b7b91ffc33deabf5c73935bce739ea85b Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:38:55 +0000 Subject: [PATCH 02/34] Fix loading which was stalled by a dependency cycle The cycle was introduced by removing void on returned Promise. Because void operator returns undefined, the promise on `IFileBrowserCommands` was never awaited (on main branch) which meant that `@jupyterlab/filebrowser-extension:widget` (the only dependant of commands token) was able to initialise as soon as the commands were activated (not waiting for the reveal/settings promises cascade); this commits restores ignoring the result of the promise and instead returns the command mapping after the promise was invoked. --- packages/filebrowser-extension/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/filebrowser-extension/src/index.ts b/packages/filebrowser-extension/src/index.ts index 1ebe07b9caf0..8d8ebd1f118a 100644 --- a/packages/filebrowser-extension/src/index.ts +++ b/packages/filebrowser-extension/src/index.ts @@ -206,7 +206,7 @@ const browser: JupyterFrontEndPlugin = { commandPalette ); - return Promise.all([app.restored, browser.model.restored]).then(() => { + void Promise.all([app.restored, browser.model.restored]).then(() => { if (treePathUpdater) { browser.model.pathChanged.connect((sender, args) => { treePathUpdater(args.newValue); @@ -250,10 +250,10 @@ const browser: JupyterFrontEndPlugin = { onSettingsChanged(settings); }); } - return { - openPath: CommandIDs.openPath - }; }); + return { + openPath: CommandIDs.openPath + }; } }; From 2f88a4ab71773f6809df99ffe969b377cc673632 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 2 Dec 2023 18:40:41 +0000 Subject: [PATCH 03/34] Implement re-opening last closed tab --- packages/docmanager-extension/src/recents.ts | 4 +- packages/docmanager/src/recents.ts | 31 +++--- packages/docmanager/src/tokens.ts | 14 ++- packages/docmanager/src/widgetmanager.ts | 48 +++++++-- .../mainmenu-extension/schema/plugin.json | 5 + packages/mainmenu-extension/src/recents.ts | 99 ++++++++++++++----- 6 files changed, 151 insertions(+), 50 deletions(-) diff --git a/packages/docmanager-extension/src/recents.ts b/packages/docmanager-extension/src/recents.ts index 12d1bb2bfc83..09a91bbd9b48 100644 --- a/packages/docmanager-extension/src/recents.ts +++ b/packages/docmanager-extension/src/recents.ts @@ -21,6 +21,8 @@ namespace CommandIDs { namespace PluginIDs { export const recentsManager = '@jupyterlab/docmanager-extension:recents'; + export const reopenClosed = + '@jupyterlab/docmanager-extension:reopen-recently-closed'; export const mainPlugin = '@jupyterlab/docmanager-extension:plugin'; } @@ -64,7 +66,7 @@ export const recentsManagerPlugin: JupyterFrontEndPlugin = { execute: () => { recentsManager.clearRecents(); }, - label: trans.__('Clear Recently Opened'), + label: trans.__('Clear Recent Documents'), caption: trans.__('Clear the list of recently opened items.') }); diff --git a/packages/docmanager/src/recents.ts b/packages/docmanager/src/recents.ts index 7878daf8c65f..acfb61c24fe9 100644 --- a/packages/docmanager/src/recents.ts +++ b/packages/docmanager/src/recents.ts @@ -90,30 +90,25 @@ export class RecentsManager implements IRecentsManager, IDisposable { /** * Add a new path to the recent list. - * - * @param path Path - * @param contentType Content type */ addRecent( - path: string, - contentType: string, - type: 'opened' | 'closed' + document: Omit, + event: 'opened' | 'closed' ): void { const recent: RecentDocument = { root: this._serverRoot, - path, - contentType + ...document }; - const recents = this._recents[type]; + const recents = this._recents[event]; // Check if it's already present; if so remove it - const existingIndex = recents.findIndex(r => r.path === path); + const existingIndex = recents.findIndex(r => r.path === document.path); if (existingIndex >= 0) { recents.splice(existingIndex, 1); } // Add to the front of the list recents.unshift(recent); - this._setRecents(recents, type); + this._setRecents(recents, event); this._recentsChanged.emit(undefined); } @@ -126,16 +121,23 @@ export class RecentsManager implements IRecentsManager, IDisposable { this._recentsChanged.emit(undefined); } + /** + * Remove the document from recents list. + */ + removeRecent(document: RecentDocument, event: 'opened' | 'closed'): void { + this._removeRecent(document.path, [event]); + } + /** * Remove a path from both lists (opened and closed) * * @param path Path to remove */ - private _removeRecent(path: string): void { + private _removeRecent(path: string, lists = ['opened', 'closed']): void { let changed = false; - for (const type of ['opened', 'closed']) { + for (const type of lists) { const recents = this._recents[type]; - const newRecents = recents.filter(r => path === r.path); + const newRecents = recents.filter(r => path !== r.path); if (recents.length !== newRecents.length) { this._setRecents(newRecents, type as 'opened' | 'closed'); } @@ -275,5 +277,4 @@ export namespace RecentsManager { namespace Private { export const stateDBKey = 'docmanager:recents'; - export const poolKey = 'docmanager:recents'; } diff --git a/packages/docmanager/src/tokens.ts b/packages/docmanager/src/tokens.ts index 64f46ef2a258..ce216ec37313 100644 --- a/packages/docmanager/src/tokens.ts +++ b/packages/docmanager/src/tokens.ts @@ -315,7 +315,15 @@ export interface IRecentsManager { /** * Add a new path to the recent list. */ - addRecent(path: string, contentType: string, type: 'opened' | 'closed'): void; + addRecent( + document: Omit, + event: 'opened' | 'closed' + ): void; + + /** + * Remove the document from recents list. + */ + removeRecent(document: RecentDocument, event: 'opened' | 'closed'): void; } /** @@ -336,4 +344,8 @@ export type RecentDocument = { * The document content type or `directory` literal for directories. */ contentType: string; + /** + * The factory that was used when the document was most recently opened or closed. + */ + factory?: string; }; diff --git a/packages/docmanager/src/widgetmanager.ts b/packages/docmanager/src/widgetmanager.ts index 06846c694157..f0fe001cf59d 100644 --- a/packages/docmanager/src/widgetmanager.ts +++ b/packages/docmanager/src/widgetmanager.ts @@ -259,12 +259,13 @@ export class DocumentWidgetManager implements IDisposable { void this.onClose(handler as Widget); return false; case 'activate-request': { - const context = this.contextForWidget(handler as Widget); + const widget = handler as Widget; + const context = this.contextForWidget(widget); if (context) { context.ready .then(() => { // contentsModel is null until the the context is ready - this._recordAsRecent(context.contentsModel!); + this._recordAsRecentlyOpened(widget, context.contentsModel!); }) .catch(() => { console.warn('Could not record the recents status for', context); @@ -336,8 +337,8 @@ export class DocumentWidgetManager implements IDisposable { return true; } if (shouldClose) { + const context = Private.contextProperty.get(widget); if (!ignoreSave) { - const context = Private.contextProperty.get(widget); if (!context) { return true; } @@ -347,6 +348,16 @@ export class DocumentWidgetManager implements IDisposable { await context.saveAs(); } } + if (context) { + context.ready + .then(() => { + // contentsModel is null until the the context is ready + this._recordAsRecentlyClosed(widget, context.contentsModel!); + }) + .catch(() => { + console.warn('Could not record the recents status for', context); + }); + } if (widget.isDisposed) { return true; } @@ -366,9 +377,12 @@ export class DocumentWidgetManager implements IDisposable { } /** - * Record the activated file, and its parent directory, as recent + * Record the activated file, and its parent directory, as recently opened. */ - private _recordAsRecent(model: Omit) { + private _recordAsRecentlyOpened( + widget: Widget, + model: Omit + ) { const recents = this._recentsManager; if (!recents) { // no-op @@ -377,13 +391,33 @@ export class DocumentWidgetManager implements IDisposable { const path = model.path; const fileType = this._registry.getFileTypeForModel(model); const contentType = fileType.contentType; - recents.addRecent(path, contentType, 'opened'); + const factory = Private.factoryProperty.get(widget)?.name; + recents.addRecent({ path, contentType, factory }, 'opened'); // Add the containing directory, too if (contentType !== 'directory') { const parent = path.lastIndexOf('/') > 0 ? path.slice(0, path.lastIndexOf('/')) : ''; - recents.addRecent(parent, 'directory', 'opened'); + recents.addRecent({ path: parent, contentType: 'directory' }, 'opened'); + } + } + + /** + * Record the activated file, and its parent directory, as recently opened. + */ + private _recordAsRecentlyClosed( + widget: Widget, + model: Omit + ) { + const recents = this._recentsManager; + if (!recents) { + // no-op + return; } + const path = model.path; + const fileType = this._registry.getFileTypeForModel(model); + const contentType = fileType.contentType; + const factory = Private.factoryProperty.get(widget)?.name; + recents.addRecent({ path, contentType, factory }, 'closed'); } /** diff --git a/packages/mainmenu-extension/schema/plugin.json b/packages/mainmenu-extension/schema/plugin.json index 4b3354212f22..aa15da2bf5d9 100644 --- a/packages/mainmenu-extension/schema/plugin.json +++ b/packages/mainmenu-extension/schema/plugin.json @@ -306,6 +306,11 @@ "command": "tabsmenu:activate-previously-used-tab", "keys": ["Accel Shift '"], "selector": "body" + }, + { + "command": "recentmenu:reopen-last", + "keys": ["Accel Shift T"], + "selector": "body" } ], "jupyter.lab.transform": true, diff --git a/packages/mainmenu-extension/src/recents.ts b/packages/mainmenu-extension/src/recents.ts index 18e9759f73e1..cac76f2e09f6 100644 --- a/packages/mainmenu-extension/src/recents.ts +++ b/packages/mainmenu-extension/src/recents.ts @@ -10,7 +10,6 @@ import { import { PathExt } from '@jupyterlab/coreutils'; import { showErrorMessage } from '@jupyterlab/apputils'; import { IRecentsManager, RecentDocument } from '@jupyterlab/docmanager'; -import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { IFileBrowserCommands } from '@jupyterlab/filebrowser'; import { IMainMenu } from '@jupyterlab/mainmenu'; @@ -28,6 +27,10 @@ namespace CommandIDs { * Open a recent document or directory */ export const openRecent = 'recentmenu:open-recent'; + /** + * Reopen recently closed tab + */ + export const reopenLast = 'recentmenu:reopen-last'; /** * Clear recent documents and directories (implemented in docmanager) */ @@ -80,7 +83,12 @@ class RecentsMenu extends Menu { // that could cause the user to mis-clicks when items move around // because another item was hidden. this.clearItems(); + this.addItem({ + command: CommandIDs.reopenLast + }); + this.addItem({ type: 'separator' }); let addSeparator = true; + let anyDirectory = false; this._manager.recentlyOpened .sort((a: RecentDocument, b: RecentDocument) => { if (a.contentType === b.contentType) { @@ -91,10 +99,12 @@ class RecentsMenu extends Menu { }) .forEach((recent: RecentDocument) => { const isDirectory = recent.contentType === 'directory'; - if (isDirectory && !this._showDirectories) { - return; - } - if (addSeparator && !isDirectory) { + if (isDirectory) { + if (!this._showDirectories) { + return; + } + anyDirectory = true; + } else if (addSeparator && anyDirectory) { addSeparator = false; this.addItem({ type: 'separator' }); } @@ -120,7 +130,7 @@ export const recentsMenuPlugin: JupyterFrontEndPlugin = { id: PLUGIN_ID, autoStart: true, requires: [IRecentsManager, IMainMenu], - optional: [ISettingRegistry, IFileBrowserCommands, ITranslator], + optional: [IFileBrowserCommands, ITranslator], activate: ( app: JupyterFrontEnd, recentsManager: IRecentsManager, @@ -134,28 +144,39 @@ export const recentsMenuPlugin: JupyterFrontEndPlugin = { // Do not show directories if the file browser is not present const showDirectories = fileBrowserCommands !== null; + const validate = async (recent: RecentDocument): Promise => { + // If path is not found, validating will remove it + const isValid = await recentsManager.validate(recent); + if (!isValid) { + await showErrorMessage( + trans.__('Could Not Open Recent'), + trans.__( + '%1 is no longer valid and will be removed from the list', + recent.path + ) + ); + } + return isValid; + }; + // Commands commands.addCommand(CommandIDs.openRecent, { execute: async args => { const recent = args.recent as RecentDocument; const path = recent.path === '' ? '/' : recent.path; - const isValid = await recentsManager.validate(recent); + const isValid = await validate(recent); if (!isValid) { - return showErrorMessage( - trans.__('Could Not Open Recent'), - trans.__( - '%1 is no longer valid and will be removed from the list', - recent.path - ) - ); + return; } - if (fileBrowserCommands) { + if (fileBrowserCommands && recent.contentType === 'directory') { // Note: prefer file browser (if available) to allow opening directories await commands.execute(fileBrowserCommands.openPath, { path }); } else { - await commands.execute('docmanager:open', { path }); + await commands.execute('docmanager:open', { + path, + factory: recent.factory + }); } - // If path not found, validating will remove it after an error message }, label: args => { const recent = args.recent as RecentDocument; @@ -164,20 +185,46 @@ export const recentsMenuPlugin: JupyterFrontEndPlugin = { isEnabled: args => recentsManager.recentlyOpened.includes(args.recent as RecentDocument) }); + + app.commands.addCommand(CommandIDs.reopenLast, { + execute: async () => { + const recent = recentsManager.recentlyClosed[0]; + if (!recent) { + return; + } + const isValid = await validate(recent); + if (!isValid) { + return; + } + await commands.execute('docmanager:open', { + path: recent.path, + factory: recent.factory + }); + recentsManager.removeRecent(recent, 'closed'); + }, + label: () => { + const recent = recentsManager.recentlyClosed[0]; + return recent + ? trans.__('Reopen %1', recent.path) + : trans.__('Reopen Closed Document'); + }, + isEnabled: () => { + return recentsManager.recentlyClosed.length !== 0; + }, + caption: trans.__('Reopen recently closed file or notebook.') + }); + + // Menu const submenu = new RecentsMenu({ commands, manager: recentsManager, showDirectories }); submenu.title.label = trans.__('Open Recent'); - mainMenu.fileMenu.addGroup( - [ - { - type: 'submenu' as Menu.ItemType, - submenu - } - ], - 1 - ); + mainMenu.fileMenu.addItem({ + type: 'submenu' as Menu.ItemType, + submenu, + rank: 1 + }); } }; From 45e24c456a973f22f397f2758d72b4dbe11d1861 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sun, 3 Dec 2023 14:28:04 +0000 Subject: [PATCH 04/34] Add tests for `RecentsManager` public API Move type to `Private`, add docstrings Automatic application of license header --- packages/docmanager/src/recents.ts | 77 +++++++--- packages/docmanager/test/recents.spec.ts | 185 +++++++++++++++++++++++ 2 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 packages/docmanager/test/recents.spec.ts diff --git a/packages/docmanager/src/recents.ts b/packages/docmanager/src/recents.ts index acfb61c24fe9..66d02085dc69 100644 --- a/packages/docmanager/src/recents.ts +++ b/packages/docmanager/src/recents.ts @@ -10,17 +10,14 @@ import { IDisposable } from '@lumino/disposable'; import { ISignal, Signal } from '@lumino/signaling'; import { IRecentsManager, RecentDocument } from './tokens'; -type RecentsDatabase = { - [key: string]: RecentDocument[]; - opened: RecentDocument[]; - closed: RecentDocument[]; -}; - +/** + * Manager for recently opened and closed documents. + */ export class RecentsManager implements IRecentsManager, IDisposable { constructor(options: RecentsManager.IOptions) { - this._serverRoot = PageConfig.getOption('serverRoot'); this._stateDB = options.stateDB; this._contentsManager = options.contents; + this.updateRootDir(); this._loadRecents().catch(r => { console.error(`Failed to load recent list from state:\n${r}`); @@ -96,8 +93,8 @@ export class RecentsManager implements IRecentsManager, IDisposable { event: 'opened' | 'closed' ): void { const recent: RecentDocument = { - root: this._serverRoot, - ...document + ...document, + root: this._serverRoot }; const recents = this._recents[event]; // Check if it's already present; if so remove it @@ -129,9 +126,27 @@ export class RecentsManager implements IRecentsManager, IDisposable { } /** - * Remove a path from both lists (opened and closed) + * Check if the recent item is valid, remove if it from both lists if it is not. + */ + async validate(recent: RecentDocument): Promise { + const valid = await this._isValid(recent); + if (!valid) { + this._removeRecent(recent.path); + } + return valid; + } + + /** + * Set server root dir. * - * @param path Path to remove + * Note: protected to allow unit-testing. + */ + protected updateRootDir() { + this._serverRoot = PageConfig.getOption('serverRoot'); + } + + /** + * Remove a path from both lists (opened and closed). */ private _removeRecent(path: string, lists = ['opened', 'closed']): void { let changed = false; @@ -140,6 +155,7 @@ export class RecentsManager implements IRecentsManager, IDisposable { const newRecents = recents.filter(r => path !== r.path); if (recents.length !== newRecents.length) { this._setRecents(newRecents, type as 'opened' | 'closed'); + changed = true; } } if (changed) { @@ -148,16 +164,8 @@ export class RecentsManager implements IRecentsManager, IDisposable { } /** - * Check if the recent item is valid, remove if it from both lists if it is not. + * Check if the path of a given recent document exists. */ - async validate(recent: RecentDocument): Promise { - const valid = await this._isValid(recent); - if (!valid) { - this._removeRecent(recent.path); - } - return valid; - } - private async _isValid(recent: RecentDocument): Promise { try { await this._contentsManager.get(recent.path, { content: false }); @@ -195,7 +203,7 @@ export class RecentsManager implements IRecentsManager, IDisposable { private async _loadRecents(): Promise { const recents = ((await this._stateDB.fetch( Private.stateDBKey - )) as RecentsDatabase) || { + )) as Private.RecentsDatabase) || { opened: [], closed: [] }; @@ -254,7 +262,7 @@ export class RecentsManager implements IRecentsManager, IDisposable { private _serverRoot: string; private _stateDB: IStateDB; private _contentsManager: Contents.IManager; - private _recents: RecentsDatabase = { + private _recents: Private.RecentsDatabase = { opened: [], closed: [] }; @@ -262,19 +270,40 @@ export class RecentsManager implements IRecentsManager, IDisposable { private _saveRoutine: ReturnType | undefined; // Whether there are local changes sent to be recorded without verification private _awaitingSaveCompletion = false; - private _isDisposed = false; - private _maxRecentsLength = 10; } +/** + * Namespace for RecentsManager statics. + */ export namespace RecentsManager { + /** + * Initialization options for RecentsManager. + */ export interface IOptions { + /** + * State database used to store the recent documents. + */ stateDB: IStateDB; + /** + * Contents manager used for path validation. + */ contents: Contents.IManager; } } namespace Private { + /** + * Key reserved in the state database. + */ export const stateDBKey = 'docmanager:recents'; + /** + * The data structure for the state database value. + */ + export type RecentsDatabase = { + [key: string]: RecentDocument[]; + opened: RecentDocument[]; + closed: RecentDocument[]; + }; } diff --git a/packages/docmanager/test/recents.spec.ts b/packages/docmanager/test/recents.spec.ts new file mode 100644 index 000000000000..f19564842f6a --- /dev/null +++ b/packages/docmanager/test/recents.spec.ts @@ -0,0 +1,185 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { PromiseDelegate } from '@lumino/coreutils'; +import { StateDB } from '@jupyterlab/statedb'; +import { ServiceManagerMock } from '@jupyterlab/services/lib/testutils'; +import { ServiceManager } from '@jupyterlab/services'; +import { RecentsManager } from '../src'; +import { PageConfig } from '@jupyterlab/coreutils'; + +describe('@jupyterlab/docmanager', () => { + let manager: TestRecentsManager; + let services: ServiceManager.IManager; + + class TestRecentsManager extends RecentsManager { + public updateRootDir() { + return super.updateRootDir(); + } + } + + beforeAll(() => { + services = new ServiceManagerMock(); + }); + + beforeEach(() => { + const stateDB = new StateDB(); + manager = new TestRecentsManager({ + stateDB, + contents: services.contents + }); + }); + + afterEach(() => { + manager.dispose(); + }); + + const setRootDir = (dir: string) => { + PageConfig.setOption('serverRoot', dir); + manager.updateRootDir(); + }; + + describe('RecentsManager', () => { + describe('#constructor()', () => { + it('should create a new recents manager', () => { + expect(manager).toBeInstanceOf(RecentsManager); + }); + }); + + describe('#isDisposed', () => { + it('should test whether the manager is disposed', () => { + expect(manager.isDisposed).toBe(false); + manager.dispose(); + expect(manager.isDisposed).toBe(true); + }); + }); + + describe('#addRecent()', () => { + it('should add a document to recently opened list', () => { + expect(manager.recentlyOpened.length).toBe(0); + manager.addRecent( + { path: 'test.py', contentType: 'text/x-python' }, + 'opened' + ); + expect(manager.recentlyOpened.length).toBe(1); + expect(manager.recentlyClosed.length).toBe(0); + expect(manager.recentlyOpened[0].path).toBe('test.py'); + manager.addRecent({ path: 'test', contentType: 'directory' }, 'opened'); + expect(manager.recentlyOpened.length).toBe(2); + }); + + it('should add a document to recently closed list', () => { + expect(manager.recentlyClosed.length).toBe(0); + manager.addRecent( + { path: 'test.py', contentType: 'text/x-python' }, + 'closed' + ); + expect(manager.recentlyClosed.length).toBe(1); + expect(manager.recentlyOpened.length).toBe(0); + expect(manager.recentlyClosed[0].path).toBe('test.py'); + manager.addRecent({ path: 'test', contentType: 'directory' }, 'closed'); + expect(manager.recentlyClosed.length).toBe(2); + }); + + it('should auto-populate root dir', () => { + manager.addRecent({ path: 'test', contentType: 'directory' }, 'opened'); + expect(manager.recentlyOpened[0].root).toBe( + PageConfig.getOption('serverRoot') + ); + }); + }); + + describe('#removeRecent()', () => { + it('should remove a document by path from correct list', () => { + const document = { path: 'test.py', contentType: 'text/x-python' }; + manager.addRecent(document, 'opened'); + manager.addRecent(document, 'closed'); + expect(manager.recentlyOpened.length).toBe(1); + expect(manager.recentlyClosed.length).toBe(1); + manager.removeRecent(manager.recentlyOpened[0], 'closed'); + expect(manager.recentlyOpened.length).toBe(1); + expect(manager.recentlyClosed.length).toBe(0); + }); + }); + + describe('#recentlyOpened()', () => { + it('should filter out items from other root directories', () => { + setRootDir('root_a'); + manager.addRecent({ path: 'a', contentType: 'directory' }, 'opened'); + setRootDir('root_b'); + manager.addRecent({ path: 'b', contentType: 'directory' }, 'opened'); + // Check for `root_b` (most recent) + expect(manager.recentlyOpened.length).toBe(1); + expect(manager.recentlyOpened[0].root).toBe('root_b'); + // Switch back to `root_a` + setRootDir('root_a'); + expect(manager.recentlyOpened.length).toBe(1); + expect(manager.recentlyOpened[0].root).toBe('root_a'); + }); + }); + + describe('#recentlyClosed()', () => { + it('should filter out items from other root directories', () => { + setRootDir('root_a'); + manager.updateRootDir(); + manager.addRecent({ path: 'a', contentType: 'directory' }, 'closed'); + setRootDir('root_b'); + manager.addRecent({ path: 'b', contentType: 'directory' }, 'closed'); + // Check for `root_b` (most recent) + expect(manager.recentlyClosed.length).toBe(1); + expect(manager.recentlyClosed[0].root).toBe('root_b'); + // Switch back to `root_a` + setRootDir('root_a'); + expect(manager.recentlyClosed.length).toBe(1); + expect(manager.recentlyClosed[0].root).toBe('root_a'); + }); + }); + + describe('#maximalRecentsLength', () => { + it('should limit the number of items', () => { + manager.maximalRecentsLength = 3; + for (let i = 0; i < 10; i++) { + const item = { path: `item${i}`, contentType: 'text/x-python' }; + manager.addRecent(item, 'opened'); + manager.addRecent(item, 'closed'); + } + expect(manager.recentlyClosed.length).toBe(3); + expect(manager.recentlyOpened.length).toBe(3); + manager.maximalRecentsLength = 2; + expect(manager.recentlyClosed.length).toBe(2); + expect(manager.recentlyOpened.length).toBe(2); + }); + it('should report current limit', () => { + manager.maximalRecentsLength = 5; + expect(manager.maximalRecentsLength).toBe(5); + }); + it('should default to a non-negative number', () => { + expect(manager.maximalRecentsLength).toBeGreaterThanOrEqual(0); + }); + }); + + describe('#changed()', () => { + it('should emit when new item is added', async () => { + const done = new PromiseDelegate(); + manager.changed.connect(() => { + done.resolve(true); + }); + const item = { path: `test.py`, contentType: 'text/x-python' }; + manager.addRecent(item, 'opened'); + expect(await done.promise).toBe(true); + }); + it('should emit when an item is removed', async () => { + const done = new PromiseDelegate(); + const item = { path: `test.py`, contentType: 'text/x-python' }; + manager.addRecent(item, 'opened'); + manager.changed.connect(() => { + done.resolve(true); + }); + manager.removeRecent(manager.recentlyOpened[0], 'opened'); + expect(await done.promise).toBe(true); + }); + }); + }); +}); From 5e484c5bb936bfd5311dd3c2feb399e356e3344d Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sun, 3 Dec 2023 16:12:56 +0000 Subject: [PATCH 05/34] Add recently closed section to the sidebar --- packages/docmanager/src/widgetmanager.ts | 13 ++- packages/running-extension/package.json | 1 + packages/running-extension/src/index.ts | 48 ++++++++++- packages/running-extension/src/recents.ts | 99 +++++++++++++++++++++++ packages/running-extension/tsconfig.json | 3 + yarn.lock | 1 + 6 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 packages/running-extension/src/recents.ts diff --git a/packages/docmanager/src/widgetmanager.ts b/packages/docmanager/src/widgetmanager.ts index f0fe001cf59d..7b1879626e44 100644 --- a/packages/docmanager/src/widgetmanager.ts +++ b/packages/docmanager/src/widgetmanager.ts @@ -349,14 +349,11 @@ export class DocumentWidgetManager implements IDisposable { } } if (context) { - context.ready - .then(() => { - // contentsModel is null until the the context is ready - this._recordAsRecentlyClosed(widget, context.contentsModel!); - }) - .catch(() => { - console.warn('Could not record the recents status for', context); - }); + await context.ready; + // Note: `contentsModel` is null until the the context is ready; + // we have to handle it after `await` rather than in a `then` + // to ensure we record it as recent before the widget gets disposed. + this._recordAsRecentlyClosed(widget, context.contentsModel!); } if (widget.isDisposed) { return true; diff --git a/packages/running-extension/package.json b/packages/running-extension/package.json index f70fb7d8dd0f..159cb019b327 100644 --- a/packages/running-extension/package.json +++ b/packages/running-extension/package.json @@ -40,6 +40,7 @@ "dependencies": { "@jupyterlab/application": "^4.1.0-alpha.4", "@jupyterlab/coreutils": "^6.1.0-alpha.4", + "@jupyterlab/docmanager": "^4.1.0-alpha.4", "@jupyterlab/docregistry": "^4.1.0-alpha.4", "@jupyterlab/rendermime-interfaces": "^3.9.0-alpha.3", "@jupyterlab/running": "^4.1.0-alpha.4", diff --git a/packages/running-extension/src/index.ts b/packages/running-extension/src/index.ts index 83f09303aec4..c37930684f8f 100644 --- a/packages/running-extension/src/index.ts +++ b/packages/running-extension/src/index.ts @@ -16,10 +16,12 @@ import { RunningSessionManagers, RunningSessions } from '@jupyterlab/running'; +import { IRecentsManager } from '@jupyterlab/docmanager'; import { ITranslator } from '@jupyterlab/translation'; import { runningIcon } from '@jupyterlab/ui-components'; import { addKernelRunningSessionManager } from './kernels'; import { addOpenTabsSessionManager } from './opentabs'; +import { addRecentlyClosedSessionManager } from './recents'; /** * The command IDs used by the running plugin. @@ -45,10 +47,32 @@ const plugin: JupyterFrontEndPlugin = { autoStart: true }; +/** + * An optional adding recently closed tabs. + */ +const recentsPlugin: JupyterFrontEndPlugin = { + activate: activateRecents, + id: '@jupyterlab/running-extension:recently-closed', + description: 'Adds recently closed documents list.', + requires: [IRunningSessionManagers, IRecentsManager, ITranslator], + autoStart: true +}; + +/** + * An optional plugin allowing to among running items. + */ +const searchPlugin: JupyterFrontEndPlugin = { + activate: activateSearch, + id: '@jupyterlab/running-extension:search-tabs', + description: 'Adds a widget to search open and closed tabs.', + requires: [IRunningSessionManagers], + autoStart: true +}; + /** * Export the plugin as default. */ -export default plugin; +export default [plugin, recentsPlugin, searchPlugin]; /** * Activate the running plugin. @@ -91,3 +115,25 @@ function activate( return runningSessionManagers; } + +function activateSearch( + app: JupyterFrontEnd, + manager: IRunningSessionManagers +): void { + console.log(app, manager); +} + +function activateRecents( + app: JupyterFrontEnd, + manager: IRunningSessionManagers, + recents: IRecentsManager, + translator: ITranslator +): void { + addRecentlyClosedSessionManager( + manager, + recents, + app.commands, + app.docRegistry, + translator + ); +} diff --git a/packages/running-extension/src/recents.ts b/packages/running-extension/src/recents.ts new file mode 100644 index 000000000000..2c406ff2efe5 --- /dev/null +++ b/packages/running-extension/src/recents.ts @@ -0,0 +1,99 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { CommandRegistry } from '@lumino/commands'; +import { PathExt } from '@jupyterlab/coreutils'; +import { IRunningSessionManagers, IRunningSessions } from '@jupyterlab/running'; +import { ITranslator } from '@jupyterlab/translation'; +import { IRecentsManager, RecentDocument } from '@jupyterlab/docmanager'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { fileIcon, LabIcon } from '@jupyterlab/ui-components'; + +/** + * Add the 'recently closed' section to the running panel. + * + * @param managers - The IRunningSessionManagers used to register this section. + * @param recentsManager - The recent documents manager. + * @param commands - The command registry. + * @param docRegistry - Document registry. + * @param translator - The translator to use. + */ +export function addRecentlyClosedSessionManager( + managers: IRunningSessionManagers, + recentsManager: IRecentsManager, + commands: CommandRegistry, + docRegistry: DocumentRegistry, + translator: ITranslator +): void { + const trans = translator.load('jupyterlab'); + + managers.add({ + name: trans.__('Recently Closed'), + running: () => { + return recentsManager.recentlyClosed.map((recent: RecentDocument) => { + return new RecentItem(recent); + }); + }, + shutdownAll: () => { + for (const widget of recentsManager.recentlyClosed) { + recentsManager.removeRecent(widget, 'closed'); + } + }, + refreshRunning: () => { + return void 0; + }, + runningChanged: recentsManager.changed, + shutdownLabel: trans.__('Forget'), + shutdownAllLabel: trans.__('Forget All'), + shutdownAllConfirmationText: trans.__( + 'Are you sure you want to clear recently closed tabs?' + ) + }); + + class RecentItem implements IRunningSessions.IRunningItem { + constructor(recent: RecentDocument) { + this._recent = recent; + } + async open() { + const recent = this._recent; + const isValid = await recentsManager.validate(recent); + if (!isValid) { + return; + } + commands + .execute('docmanager:open', { + path: recent.path, + factory: recent.factory + }) + .finally(); + recentsManager.removeRecent(recent, 'closed'); + } + shutdown() { + recentsManager.removeRecent(this._recent, 'closed'); + } + icon() { + if (!this._recent.factory) { + return fileIcon; + } + const factory = docRegistry.getWidgetFactory(this._recent.factory); + if (factory) { + for (const fileTypeName of factory.fileTypes) { + const fileType = docRegistry.getFileType(fileTypeName); + const icon = fileType?.icon; + if (icon instanceof LabIcon) { + return icon; + } + } + } + return fileIcon; + } + label() { + return PathExt.basename(this._recent.path); + } + labelTitle() { + return this._recent.path; + } + + private _recent: RecentDocument; + } +} diff --git a/packages/running-extension/tsconfig.json b/packages/running-extension/tsconfig.json index 7614656e8975..e6f24608d2b6 100644 --- a/packages/running-extension/tsconfig.json +++ b/packages/running-extension/tsconfig.json @@ -29,6 +29,9 @@ }, { "path": "../ui-components" + }, + { + "path": "../docmanager" } ] } diff --git a/yarn.lock b/yarn.lock index 55fe56c047b0..d95ba3e46d27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4527,6 +4527,7 @@ __metadata: dependencies: "@jupyterlab/application": ^4.1.0-alpha.4 "@jupyterlab/coreutils": ^6.1.0-alpha.4 + "@jupyterlab/docmanager": ^4.1.0-alpha.4 "@jupyterlab/docregistry": ^4.1.0-alpha.4 "@jupyterlab/rendermime-interfaces": ^3.9.0-alpha.3 "@jupyterlab/running": ^4.1.0-alpha.4 From a668bb3de8073af8c14382ddc30c2c976804f35c Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sun, 3 Dec 2023 23:34:57 +0000 Subject: [PATCH 06/34] Implement the searchable modal dialog --- packages/running-extension/package.json | 1 + packages/running-extension/schema/plugin.json | 5 + packages/running-extension/src/index.ts | 35 +- packages/running-extension/src/recents.ts | 9 + packages/running-extension/style/index.css | 2 + packages/running-extension/style/index.js | 2 + packages/running-extension/tsconfig.json | 9 +- packages/running/package.json | 1 + packages/running/src/index.tsx | 377 +++++++++++++++++- packages/running/style/base.css | 87 +++- yarn.lock | 2 + 11 files changed, 503 insertions(+), 27 deletions(-) diff --git a/packages/running-extension/package.json b/packages/running-extension/package.json index 159cb019b327..85f51a6a744c 100644 --- a/packages/running-extension/package.json +++ b/packages/running-extension/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@jupyterlab/application": "^4.1.0-alpha.4", + "@jupyterlab/apputils": "^4.2.0-alpha.4", "@jupyterlab/coreutils": "^6.1.0-alpha.4", "@jupyterlab/docmanager": "^4.1.0-alpha.4", "@jupyterlab/docregistry": "^4.1.0-alpha.4", diff --git a/packages/running-extension/schema/plugin.json b/packages/running-extension/schema/plugin.json index 3ef0e5421ea1..ee88df6af118 100644 --- a/packages/running-extension/schema/plugin.json +++ b/packages/running-extension/schema/plugin.json @@ -56,6 +56,11 @@ "command": "running:show-panel", "keys": ["Accel Shift B"], "selector": "body" + }, + { + "command": "running:show-modal", + "keys": ["Accel Shift A"], + "selector": "body" } ], "properties": {}, diff --git a/packages/running-extension/src/index.ts b/packages/running-extension/src/index.ts index c37930684f8f..9e821728042d 100644 --- a/packages/running-extension/src/index.ts +++ b/packages/running-extension/src/index.ts @@ -11,10 +11,12 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; +import { Dialog, ICommandPalette } from '@jupyterlab/apputils'; import { IRunningSessionManagers, RunningSessionManagers, - RunningSessions + RunningSessions, + SearchableSessions } from '@jupyterlab/running'; import { IRecentsManager } from '@jupyterlab/docmanager'; import { ITranslator } from '@jupyterlab/translation'; @@ -32,6 +34,7 @@ export namespace CommandIDs { export const kernelOpenSession = 'running:kernel-open-session'; export const kernelShutDown = 'running:kernel-shut-down'; export const showPanel = 'running:show-panel'; + export const showModal = 'running:show-modal'; } /** @@ -65,7 +68,8 @@ const searchPlugin: JupyterFrontEndPlugin = { activate: activateSearch, id: '@jupyterlab/running-extension:search-tabs', description: 'Adds a widget to search open and closed tabs.', - requires: [IRunningSessionManagers], + requires: [IRunningSessionManagers, ITranslator], + optional: [ICommandPalette], autoStart: true }; @@ -118,9 +122,32 @@ function activate( function activateSearch( app: JupyterFrontEnd, - manager: IRunningSessionManagers + manager: IRunningSessionManagers, + translator: ITranslator, + palette: ICommandPalette | null ): void { - console.log(app, manager); + const trans = translator.load('jupyterlab'); + + app.commands.addCommand(CommandIDs.showModal, { + execute: () => { + const running = new SearchableSessions(manager, translator); + const dialog = new Dialog({ + title: trans.__('Tabs and Running Sessions'), + body: running, + buttons: [Dialog.okButton({})], + hasClose: true + }); + dialog.addClass('jp-SearchableSessions-modal'); + return dialog.launch(); + }, + label: trans.__('Search Tabs and Running Sessions') + }); + if (palette) { + palette.addItem({ + command: CommandIDs.showModal, + category: trans.__('Running') + }); + } } function activateRecents( diff --git a/packages/running-extension/src/recents.ts b/packages/running-extension/src/recents.ts index 2c406ff2efe5..081ec9a98c7d 100644 --- a/packages/running-extension/src/recents.ts +++ b/packages/running-extension/src/recents.ts @@ -75,6 +75,15 @@ export function addRecentlyClosedSessionManager( if (!this._recent.factory) { return fileIcon; } + // Prefer path inference as it is more granular. + const fileTypes = docRegistry.getFileTypesForPath(this._recent.path); + for (const fileType of fileTypes) { + const icon = fileType.icon; + if (icon instanceof LabIcon) { + return icon; + } + } + // Fallback to factory-base inference. const factory = docRegistry.getWidgetFactory(this._recent.factory); if (factory) { for (const fileTypeName of factory.fileTypes) { diff --git a/packages/running-extension/style/index.css b/packages/running-extension/style/index.css index bd80d5e149a2..dbf69e9201f3 100644 --- a/packages/running-extension/style/index.css +++ b/packages/running-extension/style/index.css @@ -6,6 +6,8 @@ /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ @import url('~@lumino/widgets/style/index.css'); @import url('~@jupyterlab/ui-components/style/index.css'); +@import url('~@jupyterlab/apputils/style/index.css'); @import url('~@jupyterlab/docregistry/style/index.css'); @import url('~@jupyterlab/application/style/index.css'); +@import url('~@jupyterlab/docmanager/style/index.css'); @import url('~@jupyterlab/running/style/index.css'); diff --git a/packages/running-extension/style/index.js b/packages/running-extension/style/index.js index 8388c666d128..93cf32451f94 100644 --- a/packages/running-extension/style/index.js +++ b/packages/running-extension/style/index.js @@ -6,6 +6,8 @@ /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ import '@lumino/widgets/style/index.js'; import '@jupyterlab/ui-components/style/index.js'; +import '@jupyterlab/apputils/style/index.js'; import '@jupyterlab/docregistry/style/index.js'; import '@jupyterlab/application/style/index.js'; +import '@jupyterlab/docmanager/style/index.js'; import '@jupyterlab/running/style/index.js'; diff --git a/packages/running-extension/tsconfig.json b/packages/running-extension/tsconfig.json index e6f24608d2b6..66399e9f7edd 100644 --- a/packages/running-extension/tsconfig.json +++ b/packages/running-extension/tsconfig.json @@ -9,9 +9,15 @@ { "path": "../application" }, + { + "path": "../apputils" + }, { "path": "../coreutils" }, + { + "path": "../docmanager" + }, { "path": "../docregistry" }, @@ -29,9 +35,6 @@ }, { "path": "../ui-components" - }, - { - "path": "../docmanager" } ] } diff --git a/packages/running/package.json b/packages/running/package.json index 18444a370232..43c63a17e81e 100644 --- a/packages/running/package.json +++ b/packages/running/package.json @@ -42,6 +42,7 @@ "@jupyterlab/ui-components": "^4.1.0-alpha.4", "@lumino/coreutils": "^2.1.2", "@lumino/disposable": "^2.1.2", + "@lumino/domutils": "^2.0.1", "@lumino/messaging": "^2.0.1", "@lumino/signaling": "^2.1.2", "@lumino/widgets": "^2.3.1", diff --git a/packages/running/src/index.tsx b/packages/running/src/index.tsx index 376d97a07219..81dcb954f9af 100644 --- a/packages/running/src/index.tsx +++ b/packages/running/src/index.tsx @@ -6,11 +6,17 @@ */ import { Dialog, showDialog } from '@jupyterlab/apputils'; -import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { + ITranslator, + nullTranslator, + TranslationBundle +} from '@jupyterlab/translation'; import { caretDownIcon, caretRightIcon, closeIcon, + FilterBox, + IScore, LabIcon, PanelWithToolbar, ReactWidget, @@ -22,9 +28,10 @@ import { } from '@jupyterlab/ui-components'; import { Token } from '@lumino/coreutils'; import { DisposableDelegate, IDisposable } from '@lumino/disposable'; +import { ElementExt } from '@lumino/domutils'; import { Message } from '@lumino/messaging'; import { ISignal, Signal } from '@lumino/signaling'; -import { Widget } from '@lumino/widgets'; +import { Panel, Widget } from '@lumino/widgets'; import * as React from 'react'; /** @@ -32,6 +39,11 @@ import * as React from 'react'; */ const RUNNING_CLASS = 'jp-RunningSessions'; +/** + * The class name added to a searchable widget. + */ +const SEARCHABLE_CLASS = 'jp-SearchableSessions'; + /** * The class name added to the running terminal sessions section. */ @@ -235,11 +247,27 @@ function List(props: { shutdownLabel?: string; shutdownAllLabel?: string; shutdownItemIcon?: LabIcon; + filter?: (item: IRunningSessions.IRunningItem) => Partial | null; translator?: ITranslator; }) { + const filter = props.filter; + const items = filter + ? props.runningItems + .map(item => { + return { + item, + score: filter(item) + }; + }) + .filter(({ score }) => score !== null) + .sort((a, b) => { + return a.score!.score! - b.score!.score!; + }) + .map(({ item }) => item) + : props.runningItems; return (