From b991491aa84c357d49c2948eaf8682c9f7dfa954 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 22 Apr 2024 15:05:28 +0100 Subject: [PATCH] Implement column toggling and reordering --- schema/plugin.json | 17 ++- src/commands.ts | 79 ++++++++++ src/index.ts | 45 ++---- src/launcher.tsx | 374 ++++++++++++++++++++++++++++----------------- src/types.ts | 16 ++ style/base.css | 1 + 6 files changed, 357 insertions(+), 175 deletions(-) create mode 100644 src/commands.ts create mode 100644 src/types.ts diff --git a/schema/plugin.json b/schema/plugin.json index e76c055..2b7763f 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -3,6 +3,21 @@ "title": "jupyterlab-new-launcher", "description": "jupyterlab-new-launcher settings.", "type": "object", - "properties": {}, + "properties": { + "hiddenColumns": { + "type": "object", + "default": { + "conda_env_path": true, + "conda_raw_kernel_name": true + }, + "additionalProperties": { "type": "boolean" } + }, + "columnOrder": { + "type": "array", + "items": { + "type": "string" + } + } + }, "additionalProperties": false } diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..c3bd420 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,79 @@ +import { JupyterFrontEnd } from '@jupyterlab/application'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { TranslationBundle } from '@jupyterlab/translation'; +import { CommandIDs } from './types'; +import { ISettingsLayout } from './types'; + +export function addCommands( + app: JupyterFrontEnd, + trans: TranslationBundle, + settings: ISettingRegistry.ISettings +) { + app.commands.addCommand(CommandIDs.toggleColumn, { + label: args => { + if (args.label) { + return args.label as string; + } + if (args.id) { + const id = args.id as string; + return id[0].toLocaleUpperCase() + id.substring(1); + } + return trans.__('Toggle given column'); + }, + execute: async args => { + const id = args.id as string | undefined; + if (!id) { + return console.error('Column ID missing'); + } + const columns = + (settings.user.hiddenColumns as + | ISettingsLayout['hiddenColumns'] + | undefined) ?? {}; + if (columns[id]) { + columns[id] = false; + } else { + columns[id] = true; + } + await settings.set('hiddenColumns', columns); + }, + isToggleable: true, + isToggled: args => { + const id = args.id as string | undefined; + if (!id) { + console.error('Column ID missing for checking if toggled'); + return false; + } + const columns = + (settings.user.hiddenColumns as + | ISettingsLayout['hiddenColumns'] + | undefined) ?? {}; + return !columns[id]; + } + }); + app.commands.addCommand(CommandIDs.moveColumn, { + label: args => { + if (args.direction === 'left') { + return trans.__('Move Column Left'); + } else if (args.direction === 'right') { + return trans.__('Move Column Right'); + } else { + return trans.__('Move column left or right'); + } + }, + execute: async args => { + const order = args.order as ISettingsLayout['columnOrder']; + const id = args.id as string; + const pos = order.indexOf(id); + const shift = args.direction === 'left' ? -1 : +1; + const newPos = pos + shift; + if (newPos < 0 || newPos >= order.length) { + console.log('Cannot move the column any further'); + return; + } + const replacement = order[newPos]; + order[newPos] = id; + order[pos] = replacement; + await settings.set('columnOrder', order); + } + }); +} diff --git a/src/index.ts b/src/index.ts index 62d8a4d..48f2f5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,13 +18,8 @@ import { DockPanel, TabBar, Widget } from '@lumino/widgets'; import { NewLauncher as Launcher } from './launcher'; import { LastUsedDatabase } from './last_used'; import { FavoritesDatabase } from './favorites'; - -/** - * The command IDs used by the launcher plugin. - */ -namespace CommandIDs { - export const create = 'launcher:create'; -} +import { CommandIDs } from './types'; +import { addCommands } from './commands'; /** * Initialization data for the jupyterlab-new-launcher extension. @@ -34,8 +29,8 @@ const plugin: JupyterFrontEndPlugin = { description: 'A redesigned JupyterLab launcher', provides: ILauncher, autoStart: true, - requires: [ITranslator, IStateDB], - optional: [ILabShell, ICommandPalette, IDefaultFileBrowser, ISettingRegistry], + requires: [ITranslator, IStateDB, ISettingRegistry], + optional: [ILabShell, ICommandPalette, IDefaultFileBrowser], activate }; @@ -48,34 +43,15 @@ function activate( app: JupyterFrontEnd, translator: ITranslator, stateDB: IStateDB, + settingRegistry: ISettingRegistry, labShell: ILabShell | null, palette: ICommandPalette | null, - defaultBrowser: IDefaultFileBrowser | null, - settingRegistry: ISettingRegistry | null + defaultBrowser: IDefaultFileBrowser | null ): ILauncher { const { commands, shell } = app; const trans = translator.load('jupyterlab-new-launcher'); const model = new LauncherModel(); - console.log('JupyterLab extension jupyterlab-new-launcher is activated!'); - - if (settingRegistry) { - settingRegistry - .load(plugin.id) - .then(settings => { - console.log( - 'jupyterlab-new-launcher settings loaded:', - settings.composite - ); - }) - .catch(reason => { - console.error( - 'Failed to load settings for jupyterlab-new-launcher.', - reason - ); - }); - } - const databaseOptions = { stateDB, fetchInterval: 10000 @@ -83,6 +59,10 @@ function activate( const lastUsedDatabase = new LastUsedDatabase(databaseOptions); const favoritesDatabase = new FavoritesDatabase(databaseOptions); + settingRegistry.load(plugin.id).then(settings => { + addCommands(app, trans, settings); + }); + commands.addCommand(CommandIDs.create, { label: trans.__('New Launcher'), icon: args => (args.toolbar ? addIcon : undefined), @@ -96,6 +76,8 @@ function activate( launcher.dispose(); } }; + + const settings = await settingRegistry.load(plugin.id); await Promise.all([lastUsedDatabase.ready, favoritesDatabase.ready]); const launcher = new Launcher({ model, @@ -104,7 +86,8 @@ function activate( commands, translator, lastUsedDatabase, - favoritesDatabase + favoritesDatabase, + settings }); launcher.model = model; diff --git a/src/launcher.tsx b/src/launcher.tsx index 9380c50..ff535eb 100644 --- a/src/launcher.tsx +++ b/src/launcher.tsx @@ -12,13 +12,16 @@ import { LabIcon, caretRightIcon, Table, - UseSignal + UseSignal, + MenuSvg } from '@jupyterlab/ui-components'; import { Signal, ISignal } from '@lumino/signaling'; import * as React from 'react'; import { ILastUsedDatabase } from './last_used'; import { IFavoritesDatabase } from './favorites'; +import { ISettingsLayout, CommandIDs } from './types'; import { starIcon } from './icons'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; const STAR_BUTTON_CLASS = 'jp-starIconButton'; const KERNEL_ITEM_CLASS = 'jp-TableKernelItem'; @@ -127,10 +130,14 @@ function LauncherBody(props: { cwd: string; typeItems: IItem[]; notebookItems: IKernelItem[]; + commands: CommandRegistry; + settings: ISettingRegistry.ISettings; }): React.ReactElement { const { trans, cwd, typeItems } = props; const [query, updateQuery] = React.useState(''); - const KernelTable = Table; + + // Hoisted to avoid "Rendered fewer hooks than expected" error on toggling the Star column + const [, forceUpdate] = React.useReducer(x => x + 1, 0); const metadataAvailable = new Set(); for (const item of props.notebookItems) { @@ -185,6 +192,151 @@ function LauncherBody(props: { } ); + const columns: Table.IColumn[] = [ + { + id: 'icon', + label: trans.__('Icon'), + renderCell: (row: IKernelItem) => ( +
row.execute()}> + {row.kernelIconUrl ? ( + {row.label} + ) : ( +
+ {row.label[0].toUpperCase()} +
+ )} +
+ ), + sort: (a: IKernelItem, b: IKernelItem) => + a.command.localeCompare(b.command) + }, + { + id: 'kernel', + label: trans.__('Kernel'), + renderCell: (row: IKernelItem) => ( + { + row.execute(); + event.stopPropagation(); + }} + onKeyDown={event => { + // TODO memoize func defs for perf + if (event.key === 'Enter') { + row.execute(); + } + }} + tabIndex={0} + > + {row.label} + + ), + sort: (a: IKernelItem, b: IKernelItem) => a.label.localeCompare(b.label) + }, + ...extraColumns, + { + id: 'last-used', + label: trans.__('Last Used'), + renderCell: (row: IKernelItem) => { + return ( + + {() => { + return row.lastUsed ? ( + + {Time.formatHuman(row.lastUsed)} + + ) : ( + trans.__('Never') + ); + }} + + ); + }, + sort: (a: IKernelItem, b: IKernelItem) => { + if (a.lastUsed === b.lastUsed) { + return 0; + } + if (!a.lastUsed) { + return 1; + } + if (!b.lastUsed) { + return -1; + } + return a.lastUsed > b.lastUsed ? 1 : -1; + } + }, + { + id: 'star', + label: '', + renderCell: (row: IKernelItem) => { + const starred = row.starred; + const title = starred + ? trans.__('Click to add this kernel to favourites') + : trans.__('Click to remove the kernel from favourites'); + return ( + + ); + }, + sort: (a: IKernelItem, b: IKernelItem) => + Number(a.starred) - Number(b.starred) + } + ]; + + const [hiddenColumns, setHiddenColumns] = React.useState< + ISettingsLayout['hiddenColumns'] + >( + (props.settings.composite + .hiddenColumns as ISettingsLayout['hiddenColumns']) ?? {} + ); + const initialColumnOrder = columns.map(c => c.id); + const [columnOrder, setColumnOrder] = React.useState< + ISettingsLayout['columnOrder'] + >( + (props.settings.composite.columnOrder as ISettingsLayout['columnOrder']) ?? + initialColumnOrder + ); + const KernelTable = Table; + + const onSettings = () => { + const newHiddenColumns = + (props.settings.composite + .hiddenColumns as ISettingsLayout['hiddenColumns']) ?? {}; + if (hiddenColumns !== newHiddenColumns) { + setHiddenColumns(newHiddenColumns); + } + const newColumnOrder = + (props.settings.composite + .columnOrder as ISettingsLayout['columnOrder']) ?? initialColumnOrder; + if (columnOrder !== newColumnOrder) { + setColumnOrder(newColumnOrder); + } + }; + + React.useEffect(() => { + props.settings.changed.connect(onSettings); + return () => { + props.settings.changed.disconnect(onSettings); + }; + }); + return (

@@ -225,150 +377,81 @@ function LauncherBody(props: { title={trans.__('Open New by Kernel')} open={true} // TODO: store this in layout/state higher up > - - kernel.label.toLowerCase().indexOf(query.toLowerCase()) !== -1 - ) - .map(data => { - return { - data: data, - key: data.command + JSON.stringify(data.args) - }; - })} - blankIndicator={() => { - return
{trans.__('No entries')}
; - }} - sortKey={'kernel'} - onRowClick={event => { - const target = event.target as HTMLElement; - const row = target.closest('tr'); - if (!row) { - return; - } - const cell = target.closest('td'); - const starButton = cell?.querySelector(`.${STAR_BUTTON_CLASS}`); - if (starButton) { - return (starButton as HTMLElement).click(); +
{ + event.preventDefault(); + const contextMenu = new MenuSvg({ commands: props.commands }); + const columnsSubMenu = new MenuSvg({ commands: props.commands }); + for (const column of columns) { + columnsSubMenu.addItem({ + command: CommandIDs.toggleColumn, + args: { id: column.id, label: column.label } + }); } - const element = row.querySelector(`.${KERNEL_ITEM_CLASS}`)!; - (element as HTMLElement).click(); + columnsSubMenu.title.label = trans.__('Visible Columns'); + contextMenu.addItem({ + type: 'submenu', + submenu: columnsSubMenu + }); + const id = ( + (event.target as HTMLElement).closest( + 'th[data-id]' + ) as HTMLElement + )?.dataset['id'] as string; + contextMenu.addItem({ + command: CommandIDs.moveColumn, + args: { direction: 'left', order: columnOrder, id } + }); + contextMenu.addItem({ + command: CommandIDs.moveColumn, + args: { direction: 'right', order: columnOrder, id } + }); + contextMenu.open(event.clientX, event.clientY); }} - columns={[ - { - id: 'icon', - label: trans.__('Icon'), - renderCell: (row: IKernelItem) => ( -
row.execute()} - > - {row.kernelIconUrl ? ( - {row.label} - ) : ( -
- {row.label[0].toUpperCase()} -
- )} -
- ), - sort: (a: IKernelItem, b: IKernelItem) => - a.command.localeCompare(b.command) - }, - { - id: 'kernel', - label: trans.__('Kernel'), - renderCell: (row: IKernelItem) => ( - { - row.execute(); - event.stopPropagation(); - }} - onKeyDown={event => { - // TODO memoize func defs for perf - if (event.key === 'Enter') { - row.execute(); - } - }} - tabIndex={0} - > - {row.label} - - ), - sort: (a: IKernelItem, b: IKernelItem) => - a.label.localeCompare(b.label) - }, - ...extraColumns, - { - id: 'last-used', - label: trans.__('Last Used'), - renderCell: (row: IKernelItem) => { - return ( - - {() => { - return row.lastUsed ? ( - - {Time.formatHuman(row.lastUsed)} - - ) : ( - trans.__('Never') - ); - }} - - ); - }, - sort: (a: IKernelItem, b: IKernelItem) => { - if (a.lastUsed === b.lastUsed) { - return 0; - } - if (!a.lastUsed) { - return 1; - } - if (!b.lastUsed) { - return -1; - } - return a.lastUsed > b.lastUsed ? 1 : -1; + > + + kernel.label.toLowerCase().indexOf(query.toLowerCase()) !== -1 + ) + .map(data => { + return { + data: data, + key: data.command + JSON.stringify(data.args) + }; + })} + blankIndicator={() => { + return
{trans.__('No entries')}
; + }} + sortKey={'kernel'} + onRowClick={event => { + const target = event.target as HTMLElement; + const row = target.closest('tr'); + if (!row) { + return; } - }, - { - id: 'star', - label: '', - renderCell: (row: IKernelItem) => { - const [, forceUpdate] = React.useReducer(x => x + 1, 0); - - const starred = row.starred; - const title = starred - ? trans.__('Click to add this kernel to favourites') - : trans.__('Click to remove the kernel from favourites'); - return ( - - ); - }, - sort: (a: IKernelItem, b: IKernelItem) => - Number(a.starred) - Number(b.starred) - } - ]} - /> + const cell = target.closest('td'); + const starButton = cell?.querySelector(`.${STAR_BUTTON_CLASS}`); + if (starButton) { + return (starButton as HTMLElement).click(); + } + const element = row.querySelector(`.${KERNEL_ITEM_CLASS}`)!; + (element as HTMLElement).click(); + }} + columns={columns + .filter(column => !hiddenColumns[column.id]) + .map(column => { + return { + ...column, + rank: columnOrder.indexOf(column.id) ?? 10 + }; + }) + .sort((a, b) => { + return a.rank - b.rank; + })} + /> +

); @@ -378,6 +461,7 @@ export namespace NewLauncher { export interface IOptions extends ILauncher.IOptions { lastUsedDatabase: ILastUsedDatabase; favoritesDatabase: IFavoritesDatabase; + settings: ISettingRegistry.ISettings; } } @@ -485,6 +569,7 @@ export class NewLauncher extends Launcher { this.trans = this.translator.load('jupyterlab-new-launcher'); this._lastUsedDatabase = options.lastUsedDatabase; this._favoritesDatabase = options.favoritesDatabase; + this._settings = options.settings; } private _lastUsedDatabase: ILastUsedDatabase; private _favoritesDatabase: IFavoritesDatabase; @@ -558,10 +643,13 @@ export class NewLauncher extends Launcher { ); } protected commands: CommandRegistry; + private _settings: ISettingRegistry.ISettings; } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..aaefcc1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,16 @@ +// Copyright (c) Nebari Development Team. +// Distributed under the terms of the Modified BSD License. + +/** + * The command IDs used by the launcher plugin. + */ +export namespace CommandIDs { + export const create = 'launcher:create'; + export const moveColumn = 'new-launcher:table-move-column'; + export const toggleColumn = 'new-launcher:table-toggle-column'; +} + +export interface ISettingsLayout { + hiddenColumns: Record; + columnOrder: string[]; +} diff --git a/style/base.css b/style/base.css index 3338d0d..752e2ce 100644 --- a/style/base.css +++ b/style/base.css @@ -18,6 +18,7 @@ cursor: pointer; transition: margin var(--jp-animation-time) ease-out; list-style: none; + display: inline-block; /* contain the clickable area */ } .jp-CollapsibleSection[open] > summary {