// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /* eslint-disable @typescript-eslint/no-explicit-any */ /** * @packageDocumentation * @module running */ import { Button, TreeItem, TreeView } from '@jupyter/react-components'; import { Dialog, showDialog } from '@jupyterlab/apputils'; import type { IMovableSectionSource, ISectionEntry } from '@jupyterlab/apputils'; import type { IStateDB } from '@jupyterlab/statedb'; import type { ITranslator, TranslationBundle } from '@jupyterlab/translation'; import { nullTranslator } from '@jupyterlab/translation'; import type { IScore, LabIcon, Toolbar } from '@jupyterlab/ui-components'; import { closeIcon, collapseAllIcon, CommandToolbarButton, expandAllIcon, FilterBox, getTreeItemElement, PanelWithToolbar, ReactWidget, refreshIcon, SidePanel, tableRowsIcon, ToolbarButton, treeViewIcon, UseSignal } from '@jupyterlab/ui-components'; import { Token } from '@lumino/coreutils'; import type { IDisposable } from '@lumino/disposable'; import { DisposableDelegate } from '@lumino/disposable'; import { ElementExt } from '@lumino/domutils'; import type { Message } from '@lumino/messaging'; import type { ISignal } from '@lumino/signaling'; import { Signal } from '@lumino/signaling'; import type { AccordionPanel } from '@lumino/widgets'; import { Panel, Widget } from '@lumino/widgets'; import type { ReactNode } from 'react'; import React, { isValidElement, useCallback, useRef } from 'react'; /** * The class name added to a running widget. */ 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. */ const SECTION_CLASS = 'jp-RunningSessions-section'; /** * The class name added to a section container. */ const CONTAINER_CLASS = 'jp-RunningSessions-sectionContainer'; /** * The class name added to the running sessions items. */ const ITEM_CLASS = 'jp-RunningSessions-item'; /** * The class name added to a running session item label. */ const ITEM_LABEL_CLASS = 'jp-RunningSessions-itemLabel'; /** * The class name added to a running session item detail. */ const ITEM_DETAIL_CLASS = 'jp-RunningSessions-itemDetail'; /** * The class name added to a running session item shutdown button. */ const SHUTDOWN_BUTTON_CLASS = 'jp-RunningSessions-itemShutdown'; /** * The class name added to a running session item shutdown button. */ const SHUTDOWN_ALL_BUTTON_CLASS = 'jp-RunningSessions-shutdownAll'; /** * The class name added to icons. */ const ITEM_ICON_CLASS = 'jp-RunningSessions-icon'; /** * Modifier added to a section when flattened list view is requested. */ const LIST_VIEW_CLASS = 'jp-mod-running-list-view'; /** * The class name added to button switching between nested and flat view. */ const VIEW_BUTTON_CLASS = 'jp-RunningSessions-viewButton'; /** * The class name added to button switching between nested and flat view. */ const COLLAPSE_EXPAND_BUTTON_CLASS = 'jp-RunningSessions-collapseButton'; /** * Identifier used in the state database. */ const STATE_DB_ID = 'jp-running-sessions'; /** * The running sessions managers token. */ export const IRunningSessionManagers = new Token( '@jupyterlab/running:IRunningSessionManagers', 'A service to add running session managers.' ); /** * The running sessions token. */ export const IRunningSessionSidebar = new Token( '@jupyterlab/running:IRunningSessionsSidebar', 'A token allowing to modify the running sessions sidebar.' ); /** * The running interface. */ export interface IRunningSessionManagers { /** * Add a running item manager. * * @param manager - The running item manager. * */ add(manager: IRunningSessions.IManager): IDisposable; /** * Signal emitted when a new manager is added. */ added: ISignal; /** * Return an array of managers. */ items(): ReadonlyArray; } export class RunningSessionManagers implements IRunningSessionManagers { /** * Signal emitted when a new manager is added. */ get added(): ISignal { return this._added; } /** * Add a running item manager. * * @param manager - The running item manager. * */ add(manager: IRunningSessions.IManager): IDisposable { this._managers.push(manager); this._added.emit(manager); return new DisposableDelegate(() => { const i = this._managers.indexOf(manager); if (i > -1) { this._managers.splice(i, 1); } }); } /** * Return an iterator of launcher items. */ items(): ReadonlyArray { return this._managers; } private _added = new Signal(this); private _managers: IRunningSessions.IManager[] = []; } function Item(props: { child?: boolean; runningItem: IRunningSessions.IRunningItem; shutdownLabel?: string | ((item: IRunningSessions.IRunningItem) => string); shutdownItemIcon?: LabIcon; translator?: ITranslator; collapseToggled: ISignal; }) { const { runningItem } = props; const [collapsed, setCollapsed] = React.useState(false); // Use a ref instead of a state because the state does not have the time // to update in the callbacks const shuttingDown = useRef(false); const classList = [ITEM_CLASS]; const detail = runningItem.detail?.(); const icon = runningItem.icon(); const title = runningItem.labelTitle ? runningItem.labelTitle() : ''; const translator = props.translator || nullTranslator; const trans = translator.load('jupyterlab'); // Handle shutdown requests. const shutdownItemIcon = props.shutdownItemIcon || closeIcon; const shutdownLabel = (typeof props.shutdownLabel === 'function' ? props.shutdownLabel(runningItem) : props.shutdownLabel) ?? trans.__('Shut Down'); const shutdown = useCallback( (event: React.MouseEvent) => { shuttingDown.current = true; event.preventDefault(); runningItem.shutdown?.(); }, [runningItem, shuttingDown] ); // Materialise getter to avoid triggering it repeatedly const children = runningItem.children; // Manage collapsed state. Use the shutdown flag in lieu of `stopPropagation`. const collapsible = !!children?.length; const onClick = useCallback( (event: React.MouseEvent) => { if (shuttingDown.current) { return; } const item = getTreeItemElement(event.target as HTMLElement); if (event.currentTarget !== item) { return; } if (collapsible) { setCollapsed(!collapsed); } }, [collapsible, collapsed, shuttingDown] ); // Listen to signal to collapse from outside props.collapseToggled.connect((_emitter, newCollapseState) => setCollapsed(newCollapseState) ); if (runningItem.className) { classList.push(runningItem.className); } return ( <> {icon ? ( typeof icon === 'string' ? ( ) : ( ) ) : undefined} runningItem.open!())} > {runningItem.label()} {detail && {detail}} {runningItem.shutdown && ( )} {children && ( )} ); } function List(props: { child?: boolean; runningItems: IRunningSessions.IRunningItem[]; shutdownLabel?: string | ((item: IRunningSessions.IRunningItem) => string); shutdownItemIcon?: LabIcon; filter?: (item: IRunningSessions.IRunningItem) => Partial | null; translator?: ITranslator; collapseToggled: ISignal; }) { 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 ( <> {items.map((item, i) => ( ))} ); } interface IFilterProvider { filter(item: IRunningSessions.IRunningItem): Partial | null; filterChanged: ISignal; } class FilterWidget extends ReactWidget implements IFilterProvider { constructor(translator: ITranslator) { super(); this.filter = this.filter.bind(this); this._updateFilter = this._updateFilter.bind(this); this._trans = translator.load('jupyterlab'); this.addClass('jp-SearchableSessions-filter'); } get filterChanged(): ISignal { return this._filterChanged; } render(): JSX.Element { return ( ); } filter(item: IRunningSessions.IRunningItem): Partial | null { const labels: string[] = [this._getTextContent(item.label())]; for (const child of item.children ?? []) { labels.push(this._getTextContent(child.label())); } return this._filterFn(labels.join(' ')); } private _getTextContent(node: ReactNode): string { if (typeof node === 'string') { return node; } if (typeof node === 'number') { return '' + node; } if (typeof node === 'boolean') { return '' + node; } if (Array.isArray(node)) { return node.map(n => this._getTextContent(n)).join(' '); } if (node && isValidElement(node)) { return node.props.children .map((n: ReactNode) => this._getTextContent(n)) .join(' '); } return ''; } private _updateFilter( filterFn: (item: string) => Partial | null ): void { this._filterFn = filterFn; this._filterChanged.emit(); } private _filterFn: (item: string) => Partial | null = (_: string) => { return { score: 0 }; }; private _filterChanged = new Signal(this); private _trans: TranslationBundle; } class ListWidget extends ReactWidget { private _mode: 'tree' | 'list'; constructor( private _options: { manager: IRunningSessions.IManager; runningItems: IRunningSessions.IRunningItem[]; filterProvider?: IFilterProvider; translator?: ITranslator; collapseToggled: ISignal; } ) { super(); _options.manager.runningChanged.connect(this._emitUpdate, this); if (_options.filterProvider) { _options.filterProvider.filterChanged.connect(this._emitUpdate, this); } } /** * Whether the items are displayed as a tree view * or a flat list. */ get mode(): 'tree' | 'list' { return this._mode; } set mode(v: 'tree' | 'list') { if (this._mode !== v) { this._mode = v; this._update.emit(); } } dispose() { Signal.clearData(this); super.dispose(); } protected onBeforeShow(msg: Message): void { super.onBeforeShow(msg); this._update.emit(); } render(): JSX.Element { const options = this._options; let cached = true; return ( {() => { // Cache the running items for the initial load and request from // the service every subsequent load. if (cached) { cached = false; } else { options.runningItems = options.manager.running({ mode: this.mode }); } const classes = ['jp-TreeView']; if (this.mode === 'list') { classes.push('jp-mod-flat'); } return (
); }}
); } private _emitUpdate() { if (!this.isVisible) { return; } this._update.emit(); } private _update: Signal = new Signal(this); } /** * The Section component contains the shared look and feel for an interactive * list of kernels and sessions. * * It is specialized for each based on its props. */ export class Section extends PanelWithToolbar { constructor(options: Section.IOptions) { super(); this._listView = (options.viewMode ?? 'tree') === 'list'; this._manager = options.manager; this._filterProvider = options.filterProvider; const translator = options.translator || nullTranslator; this._trans = translator.load('jupyterlab'); this.addClass(SECTION_CLASS); this.title.label = options.manager.name; this._manager.runningChanged.connect(this._onListChanged, this); if (options.filterProvider) { options.filterProvider.filterChanged.connect(this._onListChanged, this); } this._updateEmptyClass(); const runningItems = options.manager.running({ mode: options.manager.supportsMultipleViews && !this._listView ? 'tree' : 'list' }); if (options.showToolbar !== false) { this._initializeToolbar(runningItems); } this._listWidget = new ListWidget({ runningItems, collapseToggled: this._collapseToggled, ...options }); this._listWidget.mode = options.manager.supportsMultipleViews && !this._listView ? 'tree' : 'list'; this.addWidget(this._listWidget); } /** * Toggle between list and tree view. */ toggleListView(forceOn?: boolean): void { const newState = typeof forceOn !== 'undefined' ? forceOn : !this._listView; this._listView = newState; if (this._buttons) { const switchViewButton = this._buttons['switch-view']; switchViewButton.pressed = newState; } this._collapseToggled.emit(false); if (this._manager.supportsMultipleViews === undefined) { this.toggleClass(LIST_VIEW_CLASS, newState); } this._updateButtons(); this._listWidget.mode = this._manager.supportsMultipleViews && !this._listView ? 'tree' : 'list'; this._viewChanged.emit({ mode: newState ? 'list' : 'tree' }); } /** * Dispose the resources held by the widget */ dispose(): void { if (this.isDisposed) { return; } Signal.clearData(this); super.dispose(); } private get _shutdownAllLabel(): string { return this._manager.shutdownAllLabel || this._trans.__('Shut Down All'); } private _initializeToolbar(runningItems: IRunningSessions.IRunningItem[]) { const enabled = runningItems.length > 0; const shutdownAllLabel = this._shutdownAllLabel; const shutdownTitle = `${shutdownAllLabel}?`; const onShutdown = () => { const shutdownAllConfirmationText = (typeof this._manager.shutdownAllConfirmationText === 'function' ? this._manager.shutdownAllConfirmationText() : this._manager.shutdownAllConfirmationText) ?? `${shutdownAllLabel} ${this._manager.name}`; void showDialog({ title: shutdownTitle, body: shutdownAllConfirmationText, buttons: [ Dialog.cancelButton(), Dialog.warnButton({ label: shutdownAllLabel }) ] }).then(result => { if (result.button.accept) { this._manager.shutdownAll(); } }); }; const shutdownAllButton = new ToolbarButton({ label: shutdownAllLabel, className: `${SHUTDOWN_ALL_BUTTON_CLASS}${!enabled ? ' jp-mod-disabled' : ''}`, enabled, onClick: onShutdown.bind(this) }); const switchViewButton = new ToolbarButton({ className: VIEW_BUTTON_CLASS, enabled, icon: tableRowsIcon, pressedIcon: treeViewIcon, onClick: () => this.toggleListView(), tooltip: this._trans.__('Switch to List View'), pressedTooltip: this._trans.__('Switch to Tree View') }); const collapseExpandAllButton = new ToolbarButton({ className: COLLAPSE_EXPAND_BUTTON_CLASS, enabled, icon: collapseAllIcon, pressedIcon: expandAllIcon, onClick: () => { const newState = !collapseExpandAllButton.pressed; this._collapseToggled.emit(newState); collapseExpandAllButton.pressed = newState; }, tooltip: this._trans.__('Collapse All'), pressedTooltip: this._trans.__('Expand All') }); this._buttons = { 'switch-view': switchViewButton, 'collapse-expand': collapseExpandAllButton, 'shutdown-all': shutdownAllButton }; // Update buttons once defined and before adding to DOM this._updateButtons(); this._manager.runningChanged.connect(this._updateButtons, this); // Add manager-specific buttons if (this._manager.toolbarButtons) { this._manager.toolbarButtons.forEach(button => this.toolbar.addItem( button instanceof CommandToolbarButton ? button.commandId : button.id, button ) ); } for (const name of ['collapse-expand', 'switch-view', 'shutdown-all']) { this.toolbar.addItem( name, this._buttons[name as keyof typeof this._buttons] ); } this.toolbar.addClass('jp-RunningSessions-toolbar'); this._toolbar.node.setAttribute( 'aria-label', this._trans.__('%1 toolbar', this.title.label) ); } private _onListChanged(): void { this._updateButtons(); this._updateEmptyClass(); } private _updateEmptyClass(): void { if (this._filterProvider) { const items = this._manager .running({ mode: this._manager.supportsMultipleViews && !this._listView ? 'tree' : 'list' }) .filter(this._filterProvider.filter); const empty = items.length === 0; if (empty) { this.node.classList.toggle('jp-mod-empty', true); } else { this.node.classList.toggle('jp-mod-empty', false); } } } get viewChanged(): ISignal { return this._viewChanged; } private _updateButtons(): void { if (!this._buttons) { return; } let runningItems = this._manager.running({ mode: this._manager.supportsMultipleViews && !this._listView ? 'tree' : 'list' }); const enabled = runningItems.length > 0; const hasNesting = // If the flag is undefined fallback to the old behavior // @deprecated we should remove the fallback in the next iteration this._manager.supportsMultipleViews === undefined ? runningItems.filter(item => item.children).length !== 0 : this._manager.supportsMultipleViews; const inTreeView = hasNesting && !this._buttons['switch-view'].pressed; this._buttons['switch-view'].node.style.display = hasNesting ? 'flex' : 'none'; this._buttons['collapse-expand'].node.style.display = inTreeView ? 'flex' : 'none'; this._buttons['collapse-expand'].enabled = enabled; this._buttons['switch-view'].enabled = enabled; this._buttons['shutdown-all'].enabled = enabled; } private _buttons: { 'collapse-expand': ToolbarButton; 'switch-view': ToolbarButton; 'shutdown-all': ToolbarButton; } | null = null; private _manager: IRunningSessions.IManager; private _listView: boolean = false; private _listWidget: ListWidget; private _filterProvider?: IFilterProvider; private _collapseToggled = new Signal(this); private _viewChanged = new Signal(this); private _trans: TranslationBundle; } /** * Statics for Section. */ namespace Section { /** * Initialisation options for section. */ export interface IOptions { manager: IRunningSessions.IManager; showToolbar?: boolean; filterProvider?: IFilterProvider; translator?: ITranslator; viewMode?: 'tree' | 'list'; } /** * Information about section view state. */ export interface IViewState { /** * View mode */ mode: 'tree' | 'list'; } } /** * The interface exposing the running sessions sidebar widget properties. */ export interface IRunningSessionSidebar extends IMovableSectionSource { /** * The toolbar of the running sidebar. */ readonly toolbar: Toolbar; } /** * A class that exposes the running terminal and kernel sessions. */ export class RunningSessions extends SidePanel implements IRunningSessionSidebar { /** * Construct a new running widget. */ constructor( managers: IRunningSessionManagers, translator?: ITranslator, stateDB?: IStateDB | null ) { super(); this.managers = managers; this._stateDB = stateDB ?? null; this.translator = translator ?? nullTranslator; const trans = this.translator.load('jupyterlab'); this.addClass(RUNNING_CLASS); this.toolbar.addItem( 'refresh', new ToolbarButton({ tooltip: trans.__('Refresh List'), icon: refreshIcon, onClick: () => managers.items().forEach(manager => manager.refreshRunning()) }) ); managers.items().forEach(manager => this.addSection(managers, manager)); managers.added.connect(this.addSection, this); } get accordionPanel(): AccordionPanel { return this.content as AccordionPanel; } /** * Dispose the resources held by the widget */ dispose(): void { if (this.isDisposed) { return; } this.managers.added.disconnect(this.addSection, this); super.dispose(); } // IMovableSectionSource implementation /** * Remove a section by manager name and return the widget. * * @param managerName - The name of the manager whose section to remove. * @returns The removed section widget, or null if not found. */ removeSectionById(managerName: string): Widget | null { const widget = this._sectionMap.get(managerName) ?? null; if (widget) { widget.parent = null; } return widget; } /** * Re-insert a previously removed section back into the sidebar. * * @param widget - The section widget to re-insert. */ reinsertSection(widget: Widget): void { this.addWidget(widget); } /** * Add a section for a new manager. * * @param managers Managers * @param manager New manager */ protected async addSection( managers: unknown, manager: IRunningSessions.IManager ) { const section = new Section({ manager, translator: this.translator }); this._sectionMap.set(manager.name, section); this.addWidget(section); const state = await this._getState(); const sectionsInListView = state.listViewSections; const sectionId = manager.name; if (sectionsInListView && sectionsInListView.includes(sectionId)) { section.toggleListView(true); } section.viewChanged.connect( async (_emitter, viewState: Section.IViewState) => { await this._updateState(sectionId, viewState.mode); } ); const panel = this.content as AccordionPanel; const idx = Array.from(panel.widgets).indexOf(section); if (idx >= 0) { this._sectionAdded.emit({ id: manager.name, titleNode: panel.titles[idx], widget: section }); } } /** * Update state database with the new state of a given section. */ private async _updateState(sectionId: string, mode: 'list' | 'tree') { const state = await this._getState(); let listViewSections = state.listViewSections ?? []; if (mode === 'list' && !listViewSections.includes(sectionId)) { listViewSections.push(sectionId); } else { listViewSections = listViewSections.filter(e => e !== sectionId); } const newState = { listViewSections }; if (this._stateDB) { await this._stateDB.save(STATE_DB_ID, newState); } } /** * Get current state from the state database. */ private async _getState(): Promise { if (!this._stateDB) { return {}; } return ( ((await this._stateDB.fetch( STATE_DB_ID )) as RunningSessions.IStateDBLayout) ?? {} ); } /** * Signal emitted when a section is added to this sidebar. */ get sectionAdded(): ISignal { return this._sectionAdded; } /** * Return the currently-available sections with their accordion title nodes. */ getSections(): ReadonlyArray { const panel = this.content as AccordionPanel; const result: ISectionEntry[] = []; for (const [id, widget] of this._sectionMap) { const idx = Array.from(panel.widgets).indexOf(widget); if (idx >= 0 && panel.titles[idx]) { result.push({ id, titleNode: panel.titles[idx], widget: widget }); } } return result; } protected managers: IRunningSessionManagers; protected translator: ITranslator; private _stateDB: IStateDB | null; private _sectionMap = new Map(); private readonly _sectionAdded = new Signal(this); } /** * Interfaces for RunningSessions implementation. */ namespace RunningSessions { /** * Layout of the state database. */ export interface IStateDBLayout { /** * Names of sections to be presented in the list view. */ listViewSections?: string[]; } } /** * Section but rendering its own title before the content */ class TitledSection extends Section { constructor(options: Section.IOptions) { super(options); const titleNode = document.createElement('h3'); titleNode.className = 'jp-SearchableSessions-title'; const label = titleNode.appendChild(document.createElement('span')); label.className = 'jp-SearchableSessions-titleLabel'; label.textContent = this.title.label; this.node.insertAdjacentElement('afterbegin', titleNode); } } class EmptyIndicator extends Widget { constructor(translator: ITranslator) { super(); const trans = translator.load('jupyterlab'); this.addClass('jp-SearchableSessions-emptyIndicator'); this.node.textContent = trans.__('No matches'); } } /** * A panel intended for use within `Dialog` to allow searching tabs and running sessions. */ export class SearchableSessions extends Panel { constructor(managers: IRunningSessionManagers, translator?: ITranslator) { super(); this._translator = translator ?? nullTranslator; this.addClass(RUNNING_CLASS); this.addClass(SEARCHABLE_CLASS); this._filterWidget = new FilterWidget(this._translator); this.addWidget(this._filterWidget); this._list = new SearchableSessionsList( managers, this._filterWidget, translator ); this.addWidget(this._list); this._filterWidget.filterChanged.connect(() => { this._activeIndex = 0; this._updateActive(0); }, this); } /** * Dispose the resources held by the widget */ dispose(): void { if (this.isDisposed) { return; } Signal.clearData(this); super.dispose(); } /** * Click active element when the user confirmed the choice in the dialog. */ getValue() { const items = [ ...this.node.querySelectorAll('.' + ITEM_LABEL_CLASS) ] as HTMLElement[]; const pos = Math.min(Math.max(this._activeIndex, 0), items.length - 1); items[pos].click(); } /** * Handle incoming events. */ handleEvent(event: Event): void { switch (event.type) { case 'keydown': this._evtKeydown(event as KeyboardEvent); break; } } /** * A message handler invoked on an `'after-attach'` message. */ protected onAfterAttach(_: Message): void { this._forceFocusInput(); this.node.addEventListener('keydown', this); setTimeout(() => { this._updateActive(0); }, 0); } /** * A message handler invoked on an `'after-detach'` message. */ protected onAfterDetach(_: Message): void { this.node.removeEventListener('keydown', this); } /** * Force focus on the filter input. * * Note: forces focus because this widget is intended to be used in `Dialog`, * which does not support focusing React widget nested within a non-React * widget (a limitation of `focusNodeSelector` option implementation). */ private _forceFocusInput(): void { this._filterWidget.renderPromise ?.then(() => { const jpSearch = this._filterWidget.node.querySelector('jp-search'); const input = jpSearch?.shadowRoot?.querySelector('input'); if (!input) { console.warn('Input element not found, cannot focus'); return; } input.focus(); }) .catch(console.warn); } /** * Navigate between items using up/down keys by shifting focus. */ private _evtKeydown(event: KeyboardEvent) { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { const direction = event.key === 'ArrowDown' ? +1 : -1; const wasSet = this._updateActive(direction); if (wasSet) { event.preventDefault(); } } } /** * Set and mark active item relative to the current. * * Returns whether an active item was set. */ private _updateActive(direction: -1 | 0 | 1): boolean { const items = [...this.node.querySelectorAll('.' + ITEM_CLASS)].filter(e => e.checkVisibility() ) as HTMLElement[]; if (!items.length) { return false; } for (const item of items) { if (item.classList.contains('jp-mod-active')) { item.classList.toggle('jp-mod-active', false); } } const currentIndex = this._activeIndex; let newIndex: number | null = null; if (currentIndex === -1) { // First or last newIndex = direction === +1 ? 0 : items.length - 1; } else { newIndex = Math.min( Math.max(currentIndex + direction, 0), items.length - 1 ); } if (newIndex !== null) { items[newIndex].classList.add('jp-mod-active'); ElementExt.scrollIntoViewIfNeeded(this._list.node, items[newIndex]); this._activeIndex = newIndex; return true; } return false; } private _translator: ITranslator; private _filterWidget: FilterWidget; private _activeIndex = 0; private _list: SearchableSessionsList; } /** * A panel list of searchable sessions. */ export class SearchableSessionsList extends Panel { constructor( managers: IRunningSessionManagers, filterWidget: FilterWidget, translator?: ITranslator ) { super(); this._managers = managers; this._translator = translator ?? nullTranslator; this._filterWidget = filterWidget; this.addClass('jp-SearchableSessions-list'); this._emptyIndicator = new EmptyIndicator(this._translator); this.addWidget(this._emptyIndicator); managers.items().forEach(manager => this.addSection(managers, manager)); managers.added.connect(this.addSection, this); } /** * Dispose the resources held by the widget */ dispose(): void { if (this.isDisposed) { return; } this._managers.added.disconnect(this.addSection, this); super.dispose(); } /** * Add a section for a new manager. * * @param managers Managers * @param manager New manager */ protected addSection(managers: unknown, manager: IRunningSessions.IManager) { const section = new TitledSection({ manager, translator: this._translator, showToolbar: false, filterProvider: this._filterWidget, viewMode: 'list' }); // Do not use tree view in searchable list section.toggleListView(true); this.addWidget(section); // Move empty indicator to the end this.addWidget(this._emptyIndicator); } private _managers: IRunningSessionManagers; private _translator: ITranslator; private _emptyIndicator: EmptyIndicator; private _filterWidget: FilterWidget; } /** * The namespace for the `IRunningSessions` class statics. */ export namespace IRunningSessions { /** * A manager of running items grouped under a single section. */ export interface IManager { /** * Name that is shown to the user in plural. */ name: string; /** * Called when the shutdown all button is pressed. */ shutdownAll(): void; /** * List the running models. * * If the manager supports tree view, it should set the flag * {@link supportsMultipleViews}. * It must return nested item if mode is `tree`. * Otherwise it must return a flat list. */ running(options: { mode: 'tree' | 'list' }): IRunningItem[]; /** * Force a refresh of the running models. */ refreshRunning(): void; /** * A signal that should be emitted when the item list has changed. */ runningChanged: ISignal; /** * A string used to describe the shutdown action. */ shutdownLabel?: string | ((item: IRunningSessions.IRunningItem) => string); /** * A string used to describe the shutdown all action. */ shutdownAllLabel?: string; /** * A string used as the body text in the shutdown all confirmation dialog. */ shutdownAllConfirmationText?: string | (() => string); /** * The icon to show for shutting down an individual item in this section. */ shutdownItemIcon?: LabIcon; /** * Used to add arbitrary buttons to this section */ toolbarButtons?: (ToolbarButton | CommandToolbarButton)[]; /** * Whether the manager supports tree view for its items * or only a flat list. */ supportsMultipleViews?: boolean; } /** * A running item. */ export interface IRunningItem { /** * Optional child nodes that belong to a top-level running item. */ children?: IRunningItem[]; /** * Optional CSS class name to add to the running item. */ className?: string; /** * Optional context hint to add to the `data-context` attribute of an item. */ context?: string; /** * Called when the running item is clicked. */ open?: () => void; /** * Called when the shutdown button is pressed on a particular item. */ shutdown?: () => void; /** * The `LabIcon` to use as the icon for the running item or the string * `src` URL. */ icon: () => LabIcon | string; /** * Called to determine the label for each item. */ label: () => ReactNode; /** * Called to determine the `title` attribute for each item, which is * revealed on hover. */ labelTitle?: () => string; /** * Called to determine the `detail` attribute, which is shown optionally in * a column after the label. */ detail?: () => string; } }