diff --git a/.github/labeler.yml b/.github/labeler.yml index 303b4f3f8385..460ef50cda2e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -284,6 +284,14 @@ pkg:mermaid: - packages/mermaid-extension/**/* - packages/mermaid-extension/* +pkg:metadataform: +- changed-files: + - any-glob-to-any-file: + - packages/metadataform/**/* + - packages/metadataform/* + - packages/metadataform-extension/**/* + - packages/metadataform-extension/* + pkg:notebook: - changed-files: - any-glob-to-any-file: diff --git a/buildutils/package.json b/buildutils/package.json index fb40394d0575..2abf433b931f 100644 --- a/buildutils/package.json +++ b/buildutils/package.json @@ -43,7 +43,6 @@ "dependencies": { "@yarnpkg/core": "^3.0.0", "@yarnpkg/parsers": "^2.0.0", - "child_process": "~1.0.2", "commander": "^9.4.1", "crypto": "~1.0.1", "dependency-graph": "^0.11.0", diff --git a/buildutils/src/ensure-repo.ts b/buildutils/src/ensure-repo.ts index 73238e2a704c..bf87e0c59f5d 100644 --- a/buildutils/src/ensure-repo.ts +++ b/buildutils/src/ensure-repo.ts @@ -43,11 +43,17 @@ const URL_CONFIG = { // Data to ignore. const MISSING: Dict = { '@jupyterlab/coreutils': ['path'], - '@jupyterlab/buildutils': ['assert', 'fs', 'path', 'webpack'], + '@jupyterlab/buildutils': [ + 'assert', + 'child_process', + 'fs', + 'path', + 'webpack' + ], '@jupyterlab/builder': ['path'], '@jupyterlab/galata': ['fs', 'path', '@jupyterlab/galata'], '@jupyterlab/markedparser-extension': ['Tokens', 'MarkedOptions'], - '@jupyterlab/testing': ['fs', 'path'], + '@jupyterlab/testing': ['child_process', 'fs', 'path'], '@jupyterlab/vega5-extension': ['vega-embed'] }; diff --git a/galata/src/helpers/sidebar.ts b/galata/src/helpers/sidebar.ts index 58600c3596c8..ca6a58e4111e 100644 --- a/galata/src/helpers/sidebar.ts +++ b/galata/src/helpers/sidebar.ts @@ -267,6 +267,45 @@ export class SidebarHelper { }); } + /** + * Set the sidebar width + * + * @param width Sidebar width in pixels + * @param side Which sidebar to set: 'left' or 'right' + */ + async setWidth( + width = 251, + side: galata.SidebarPosition = 'left' + ): Promise { + if (!(await this.isOpen(side))) { + return false; + } + + const handles = this.page.locator( + '#jp-main-split-panel > .lm-SplitPanel-handle:not(.lm-mod-hidden)' + ); + const splitHandle = + side === 'left' + ? await handles.first().elementHandle() + : await handles.last().elementHandle(); + const handleBBox = await splitHandle!.boundingBox(); + + await this.page.mouse.move( + handleBBox!.x + 0.5 * handleBBox!.width, + handleBBox!.y + 0.5 * handleBBox!.height + ); + await this.page.mouse.down(); + await this.page.mouse.move( + side === 'left' + ? 33 + width + : this.page.viewportSize()!.width - 33 - width, + handleBBox!.y + 0.5 * handleBBox!.height + ); + await this.page.mouse.up(); + + return true; + } + /** * Get the selector for a given tab * diff --git a/galata/test/documentation/customization.test.ts b/galata/test/documentation/customization.test.ts index d0cb61e9c45d..5518625a1200 100644 --- a/galata/test/documentation/customization.test.ts +++ b/galata/test/documentation/customization.test.ts @@ -2,7 +2,6 @@ // Distributed under the terms of the Modified BSD License. import { expect, galata, test } from '@jupyterlab/galata'; -import { setSidebarWidth } from './utils'; test.use({ autoGoto: false, @@ -23,7 +22,7 @@ test.describe('Default', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.menu.clickMenuItem('File>New>Terminal'); @@ -42,7 +41,7 @@ test.describe('Default', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.dblclick( '[aria-label="File Browser Section"] >> text=notebooks' @@ -75,7 +74,7 @@ test.describe('Default', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.click('text=Tabs'); @@ -95,7 +94,7 @@ test.describe('Default', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.dblclick( '[aria-label="File Browser Section"] >> text=notebooks' @@ -199,13 +198,13 @@ test.describe('Customized', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.menu.clickMenuItem('File>New>Terminal'); await page.waitForSelector('.jp-Terminal'); - await setSidebarWidth(page, 271, 'right'); + await page.sidebar.setWidth(271, 'right'); expect(await page.screenshot()).toMatchSnapshot( 'customized-terminal-position-single.png' @@ -220,7 +219,7 @@ test.describe('Customized', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.dblclick( '[aria-label="File Browser Section"] >> text=notebooks' @@ -247,7 +246,7 @@ test.describe('Customized', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.click('text=Tabs'); @@ -267,7 +266,7 @@ test.describe('Customized', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.dblclick( '[aria-label="File Browser Section"] >> text=notebooks' diff --git a/galata/test/documentation/debugger.test.ts b/galata/test/documentation/debugger.test.ts index 89d5eeb3a78e..e5c898640773 100644 --- a/galata/test/documentation/debugger.test.ts +++ b/galata/test/documentation/debugger.test.ts @@ -7,7 +7,7 @@ import { IJupyterLabPageFixture, test } from '@jupyterlab/galata'; -import { positionMouseOver, setSidebarWidth } from './utils'; +import { positionMouseOver } from './utils'; test.use({ autoGoto: false, @@ -43,7 +43,7 @@ test.describe('Debugger', () => { await page.debugger.switchOn(); await page.waitForCondition(() => page.debugger.isOpen()); - await setSidebarWidth(page, 251, 'right'); + await page.sidebar.setWidth(251, 'right'); expect( await page.screenshot({ clip: { y: 62, x: 780, width: 210, height: 32 } }) @@ -57,7 +57,7 @@ test.describe('Debugger', () => { await page.debugger.switchOn(); await page.waitForCondition(() => page.debugger.isOpen()); - await setSidebarWidth(page, 251, 'right'); + await page.sidebar.setWidth(251, 'right'); await setBreakpoint(page); @@ -105,7 +105,7 @@ test.describe('Debugger', () => { await page.debugger.switchOn(); await page.waitForCondition(() => page.debugger.isOpen()); - await setSidebarWidth(page, 251, 'right'); + await page.sidebar.setWidth(251, 'right'); await setBreakpoint(page); @@ -131,7 +131,7 @@ test.describe('Debugger', () => { await page.debugger.switchOn(); await page.waitForCondition(() => page.debugger.isOpen()); - await setSidebarWidth(page, 251, 'right'); + await page.sidebar.setWidth(251, 'right'); await expect( page.locator('jp-button.jp-PauseOnExceptions') @@ -201,7 +201,7 @@ test.describe('Debugger', () => { '[data-id="jp-debugger-sidebar"]' ); await sidebar.click(); - await setSidebarWidth(page, 251, 'right'); + await page.sidebar.setWidth(251, 'right'); // Inject mouse pointer await page.evaluate( @@ -225,7 +225,7 @@ test.describe('Debugger', () => { await page.debugger.switchOn(); await page.waitForCondition(() => page.debugger.isOpen()); - await setSidebarWidth(page, 251, 'right'); + await page.sidebar.setWidth(251, 'right'); await setBreakpoint(page); @@ -252,7 +252,7 @@ test.describe('Debugger', () => { await page.debugger.switchOn(); await page.waitForCondition(() => page.debugger.isOpen()); - await setSidebarWidth(page, 251, 'right'); + await page.sidebar.setWidth(251, 'right'); await setBreakpoint(page); @@ -283,7 +283,7 @@ test.describe('Debugger', () => { await page.debugger.switchOn(); await page.waitForCondition(() => page.debugger.isOpen()); - await setSidebarWidth(page, 251, 'right'); + await page.sidebar.setWidth(251, 'right'); await setBreakpoint(page); @@ -313,7 +313,7 @@ test.describe('Debugger', () => { await page.debugger.switchOn(); await page.waitForCondition(() => page.debugger.isOpen()); - await setSidebarWidth(page, 251, 'right'); + await page.sidebar.setWidth(251, 'right'); await setBreakpoint(page); @@ -340,7 +340,7 @@ test.describe('Debugger', () => { async function createNotebook(page: IJupyterLabPageFixture) { await page.notebook.createNew(); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.waitForSelector('text=Python 3 (ipykernel) | Idle'); } diff --git a/galata/test/documentation/export_notebook.test.ts b/galata/test/documentation/export_notebook.test.ts index 69792ccbf73b..8787ebd5af95 100644 --- a/galata/test/documentation/export_notebook.test.ts +++ b/galata/test/documentation/export_notebook.test.ts @@ -2,7 +2,6 @@ // Distributed under the terms of the Modified BSD License. import { expect, galata, test } from '@jupyterlab/galata'; -import { setSidebarWidth } from './utils'; test.use({ autoGoto: false, @@ -14,7 +13,7 @@ test.describe('Export Notebook', () => { test('Export Menu', async ({ page }) => { await page.goto(); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.dblclick( '[aria-label="File Browser Section"] >> text=notebooks' @@ -40,7 +39,7 @@ test.describe('Export Notebook', () => { test('Slides', async ({ page }) => { await page.goto(); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page .locator('[aria-label="File Browser Section"]') diff --git a/galata/test/documentation/extension_manager.test.ts b/galata/test/documentation/extension_manager.test.ts index ccd96c702f73..52b4dd1da88f 100644 --- a/galata/test/documentation/extension_manager.test.ts +++ b/galata/test/documentation/extension_manager.test.ts @@ -7,7 +7,7 @@ import { IJupyterLabPageFixture, test } from '@jupyterlab/galata'; -import { setSidebarWidth, stubGitHubUserIcons } from './utils'; +import { stubGitHubUserIcons } from './utils'; import { default as extensionsList } from './data/extensions.json'; import { default as allExtensionsList } from './data/extensions-search-all.json'; import { default as drawioExtensionsList } from './data/extensions-search-drawio.json'; @@ -198,5 +198,5 @@ async function openExtensionSidebar(page: IJupyterLabPageFixture) { '.jp-extensionmanager-view >> .jp-AccordionPanel-title[aria-expanded="false"] >> text=Warning' ); - await setSidebarWidth(page); + await page.sidebar.setWidth(); } diff --git a/galata/test/documentation/general.test.ts b/galata/test/documentation/general.test.ts index 395f2170ed61..359aa4d7e96d 100644 --- a/galata/test/documentation/general.test.ts +++ b/galata/test/documentation/general.test.ts @@ -3,12 +3,7 @@ import { expect, galata, test } from '@jupyterlab/galata'; import path from 'path'; -import { - generateArrow, - positionMouse, - positionMouseOver, - setSidebarWidth -} from './utils'; +import { generateArrow, positionMouse, positionMouseOver } from './utils'; test.use({ autoGoto: false, @@ -26,7 +21,7 @@ test.describe('General', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); // README.md in preview await page.click('text=README.md', { @@ -89,7 +84,7 @@ test.describe('General', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.dblclick('[aria-label="File Browser Section"] >> text=data'); // Wait for the `data` folder to load to have something to blur @@ -114,7 +109,7 @@ test.describe('General', () => { await page.notebook.createNew(); await page.click('[title="Property Inspector"]'); - await setSidebarWidth(page, 251, 'right'); + await page.sidebar.setWidth(251, 'right'); expect( await page.screenshot({ @@ -294,7 +289,7 @@ test.describe('General', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.dblclick( '[aria-label="File Browser Section"] >> text=notebooks' @@ -375,7 +370,7 @@ test.describe('General', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); // Open jupyterlab.md await page.dblclick( @@ -402,7 +397,7 @@ test.describe('General', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); // Open Data.ipynb await page.dblclick( @@ -466,7 +461,7 @@ test.describe('General', () => { test('Heading anchor', async ({ page }, testInfo) => { await page.goto(); - await setSidebarWidth(page); + await page.sidebar.setWidth(); // Open Data.ipynb await page.dblclick( @@ -516,7 +511,7 @@ test.describe('General', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); // Open Data.ipynb await page.dblclick( @@ -551,7 +546,7 @@ test.describe('General', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); // Open a terminal await page.click('text=File'); @@ -607,7 +602,7 @@ test.describe('General', () => { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.dblclick('[aria-label="File Browser Section"] >> text=data'); await page.click('text=README.md', { diff --git a/galata/test/documentation/internationalization.test.ts b/galata/test/documentation/internationalization.test.ts index 9b85720a2b10..bc0f5466e986 100644 --- a/galata/test/documentation/internationalization.test.ts +++ b/galata/test/documentation/internationalization.test.ts @@ -2,7 +2,6 @@ // Distributed under the terms of the Modified BSD License. import { expect, galata, test } from '@jupyterlab/galata'; -import { setSidebarWidth } from './utils'; test.use({ autoGoto: false, @@ -14,7 +13,7 @@ test.describe('Internationalization', () => { test('Menu', async ({ page }) => { await page.goto(); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.click('text=Settings'); await page.click('.lm-Menu ul[role="menu"] >> text=Language'); @@ -27,7 +26,7 @@ test.describe('Internationalization', () => { test('Confirm language', async ({ page }) => { await page.goto(); - await setSidebarWidth(page); + await page.sidebar.setWidth(); await page.click('text=Settings'); await page.click('.lm-Menu ul[role="menu"] >> text=Language'); @@ -69,7 +68,7 @@ test.describe('Internationalization', () => { // Wait for the launcher to be loaded await page.waitForSelector('text=README.md'); - await setSidebarWidth(page); + await page.sidebar.setWidth(); expect(await page.screenshot()).toMatchSnapshot('language_chinese.png'); }); diff --git a/galata/test/documentation/internationalization.test.ts-snapshots/language-chinese-documentation-linux.png b/galata/test/documentation/internationalization.test.ts-snapshots/language-chinese-documentation-linux.png index e184a4c00d77..03542ba99af0 100644 Binary files a/galata/test/documentation/internationalization.test.ts-snapshots/language-chinese-documentation-linux.png and b/galata/test/documentation/internationalization.test.ts-snapshots/language-chinese-documentation-linux.png differ diff --git a/galata/test/documentation/overview.test.ts b/galata/test/documentation/overview.test.ts index a07e2a3c5077..45e08d239233 100644 --- a/galata/test/documentation/overview.test.ts +++ b/galata/test/documentation/overview.test.ts @@ -2,7 +2,6 @@ // Distributed under the terms of the Modified BSD License. import { expect, galata, test } from '@jupyterlab/galata'; -import { setSidebarWidth } from './utils'; test.use({ autoGoto: false, @@ -56,7 +55,7 @@ async function openOverview(page) { }` }); - await setSidebarWidth(page); + await page.sidebar.setWidth(); // Open Data.ipynb await page.dblclick('[aria-label="File Browser Section"] >> text=notebooks'); diff --git a/galata/test/documentation/utils.ts b/galata/test/documentation/utils.ts index 75aeed354375..9de1de24bc22 100644 --- a/galata/test/documentation/utils.ts +++ b/galata/test/documentation/utils.ts @@ -93,39 +93,6 @@ export async function positionMouseOver( }); } -/** - * Set the sidebar width - * - * @param page Page object - * @param width Sidebar width in pixels - * @param side Which sidebar to set: 'left' or 'right' - */ -export async function setSidebarWidth( - page: Page, - width = 251, - side: 'left' | 'right' = 'left' -): Promise { - const handles = page.locator( - '#jp-main-split-panel > .lm-SplitPanel-handle:not(.lm-mod-hidden)' - ); - const splitHandle = - side === 'left' - ? await handles.first().elementHandle() - : await handles.last().elementHandle(); - const handleBBox = await splitHandle.boundingBox(); - - await page.mouse.move( - handleBBox.x + 0.5 * handleBBox.width, - handleBBox.y + 0.5 * handleBBox.height - ); - await page.mouse.down(); - await page.mouse.move( - side === 'left' ? 33 + width : page.viewportSize().width - 33 - width, - handleBBox.y + 0.5 * handleBBox.height - ); - await page.mouse.up(); -} - export async function stubGitHubUserIcons(page: Page): Promise { // stub out github user icons // only first and last icon for now diff --git a/galata/test/jupyterlab/notebook-toolbar.test.ts b/galata/test/jupyterlab/notebook-toolbar.test.ts index e86150f72d2e..a709c486b829 100644 --- a/galata/test/jupyterlab/notebook-toolbar.test.ts +++ b/galata/test/jupyterlab/notebook-toolbar.test.ts @@ -14,6 +14,60 @@ async function populateNotebook(page: IJupyterLabPageFixture) { await page.notebook.addCell('code', '2 ** 3'); } +/** + * Adds an item in notebook toolbar using the extension system. + */ +async function addWidgetsInNotebookToolbar( + page: IJupyterLabPageFixture, + notebook: string, + content: string, + afterElem: string +) { + await page.notebook.activate(notebook); + await page.evaluate( + options => { + const { content, afterElem } = options; + + // A minimal widget class, with required field for adding an item in toolbar. + class MinimalWidget { + constructor(node) { + this.node = node; + } + addClass() {} + node = null; + processMessage() {} + hasClass(name) { + return false; + } + _parent = null; + get parent() { + return this._parent; + } + set parent(p) { + this._parent?.layout.removeWidget(this); + this._parent = p; + } + } + + const plugin = { + id: 'my-test-plugin', + activate: app => { + const toolbar = app.shell.activeWidget.toolbar; + const node = document.createElement('div'); + node.classList.add('jp-CommandToolbarButton'); + node.classList.add('jp-Toolbar-item'); + node.textContent = content; + const widget = new MinimalWidget(node); + toolbar.insertAfter(afterElem, content, widget); + } + }; + jupyterapp.registerPlugin(plugin); + jupyterapp.activatePlugin('my-test-plugin'); + }, + { content, afterElem } + ); +} + test.describe('Notebook Toolbar', () => { test.beforeEach(async ({ page }) => { await page.notebook.createNew(fileName); @@ -141,3 +195,127 @@ test('Toolbar items act on owner widget', async ({ page }) => { expect(classlistEnd.split(' ')).toContain('jp-mod-current'); expect(await page.notebook.getCellCount()).toEqual(2); }); + +test.describe('Reactive toolbar', () => { + test.beforeEach(async ({ page }) => { + await page.notebook.createNew(fileName); + }); + + test('Reducing toolbar width should display opener item', async ({ + page + }) => { + const toolbar = page.locator('.jp-NotebookPanel-toolbar'); + await expect(toolbar.locator('.jp-Toolbar-item:visible')).toHaveCount(14); + await expect( + toolbar.locator('.jp-Toolbar-responsive-opener') + ).not.toBeVisible(); + + await page.sidebar.setWidth(520); + + await expect( + toolbar.locator('.jp-Toolbar-responsive-opener') + ).toBeVisible(); + + await expect(toolbar.locator('.jp-Toolbar-item:visible')).toHaveCount(12); + }); + + test('Items in popup toolbar should have the same order', async ({ + page + }) => { + const toolbar = page.locator('.jp-NotebookPanel-toolbar'); + + await page.sidebar.setWidth(520); + + await toolbar.locator('.jp-Toolbar-responsive-opener').click(); + + // A 'visible' selector is added because there is another response popup element + // when running in playwright (don't know where it come from, it is an empty + // toolbar). + const popupToolbar = page.locator( + 'body > .jp-Toolbar-responsive-popup:visible' + ); + const popupToolbarItems = popupToolbar.locator('.jp-Toolbar-item:visible'); + await expect(popupToolbarItems).toHaveCount(3); + + const itemChildClasses = [ + '.jp-DebuggerBugButton', + '.jp-Toolbar-kernelName', + '.jp-Notebook-ExecutionIndicator' + ]; + + for (let i = 0; i < (await popupToolbarItems.count()); i++) { + await expect( + popupToolbarItems.nth(i).locator(itemChildClasses[i]) + ).toHaveCount(1); + } + }); + + test('Item added from extension should be correctly placed', async ({ + page + }) => { + const toolbar = page.locator('.jp-NotebookPanel-toolbar'); + await addWidgetsInNotebookToolbar( + page, + 'notebook.ipynb', + 'new item 1', + 'cellType' + ); + + const toolbarItems = toolbar.locator('.jp-Toolbar-item:visible'); + await expect(toolbarItems.nth(10)).toHaveText('new item 1'); + }); + + test('Item should be correctly placed after resize', async ({ page }) => { + const toolbar = page.locator('.jp-NotebookPanel-toolbar'); + await addWidgetsInNotebookToolbar( + page, + 'notebook.ipynb', + 'new item 1', + 'cellType' + ); + + await page.sidebar.setWidth(600); + await toolbar.locator('.jp-Toolbar-responsive-opener').click(); + + // A 'visible' selector is added because there is another response popup element + // when running in playwright (don't know where it come from, it is an empty + // toolbar). + const popupToolbar = page.locator( + 'body > .jp-Toolbar-responsive-popup:visible' + ); + const popupToolbarItems = popupToolbar.locator('.jp-Toolbar-item:visible'); + + await expect(popupToolbarItems.nth(1)).toHaveText('new item 1'); + + await page.sidebar.setWidth(); + const toolbarItems = toolbar.locator('.jp-Toolbar-item:visible'); + await expect(toolbarItems.nth(10)).toHaveText('new item 1'); + }); + + test('Item added from extension should be correctly placed in popup toolbar', async ({ + page + }) => { + const toolbar = page.locator('.jp-NotebookPanel-toolbar'); + + await page.sidebar.setWidth(600); + + await addWidgetsInNotebookToolbar( + page, + 'notebook.ipynb', + 'new item 1', + 'cellType' + ); + + await toolbar.locator('.jp-Toolbar-responsive-opener').click(); + + // A 'visible' selector is added because there is another response popup element + // when running in playwright (don't know where it come from, it is an empty + // toolbar). + const popupToolbar = page.locator( + 'body > .jp-Toolbar-responsive-popup:visible' + ); + const popupToolbarItems = popupToolbar.locator('.jp-Toolbar-item:visible'); + + await expect(popupToolbarItems.nth(1)).toHaveText('new item 1'); + }); +}); diff --git a/packages/application/src/lab.ts b/packages/application/src/lab.ts index d1e205819b67..87b284494a17 100644 --- a/packages/application/src/lab.ts +++ b/packages/application/src/lab.ts @@ -199,6 +199,72 @@ export class JupyterLab extends JupyterFrontEnd { }); } + /** + * Override keydown handling to prevent command shortcuts from preventing user input. + * + * This introduces a slight delay to the command invocation, but no delay to user input. + */ + protected evtKeydown(event: KeyboardEvent): void { + // Process select keys which may call `preventDefault()` immediately + if ( + ['Tab', 'ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft'].includes( + event.key + ) + ) { + return this.commands.processKeydownEvent(event); + } + // Process remaining events conditionally, depending on whether they would lead to text insertion + const causesInputPromise = Promise.race([ + new Promise(resolve => { + if (!event.target) { + return resolve(false); + } + event.target.addEventListener( + 'beforeinput', + (inputEvent: InputEvent) => { + switch (inputEvent.inputType) { + case 'historyUndo': + case 'historyRedo': { + if ( + inputEvent.target instanceof Element && + inputEvent.target.closest('[data-jp-undoer]') + ) { + // Allow to use custom undo/redo bindings on `jpUndoer`s + inputEvent.preventDefault(); + return resolve(false); + } + break; + } + case 'insertLineBreak': { + if ( + inputEvent.target instanceof Element && + inputEvent.target.closest('.jp-Cell') + ) { + // Allow to override the default action of Shift + Enter on cells as this is used for cell execution + inputEvent.preventDefault(); + return resolve(false); + } + break; + } + } + return resolve(true); + }, + { once: true } + ); + }), + new Promise(resolve => { + setTimeout(() => resolve(false), Private.INPUT_GUARD_TIMEOUT); + }) + ]); + causesInputPromise + .then(willCauseInput => { + if (!willCauseInput) { + this.commands.processKeydownEvent(event); + } + }) + .catch(console.warn); + } + private _info: JupyterLab.IInfo = JupyterLab.defaultInfo; private _paths: JupyterFrontEnd.IPaths; private _allPluginsActivated = new PromiseDelegate(); @@ -373,3 +439,18 @@ export namespace JupyterLab { | JupyterFrontEndPlugin[]; } } + +/** + * A namespace for module-private functionality. + */ +namespace Private { + /** + * The delay for invoking a command introduced by user input guard. + * Decreasing this value may lead to commands incorrectly triggering + * on user input. Increasing this value will lead to longer delay for + * command invocation. Note that user input is never delayed. + * + * The value represents the number in milliseconds. + */ + export const INPUT_GUARD_TIMEOUT = 10; +} diff --git a/packages/apputils/package.json b/packages/apputils/package.json index efb176ea5086..e33ca950b9c4 100644 --- a/packages/apputils/package.json +++ b/packages/apputils/package.json @@ -65,12 +65,12 @@ "@lumino/widgets": "^2.3.1", "@types/react": "^18.0.26", "react": "^18.2.0", - "sanitize-html": "~2.7.3" + "sanitize-html": "~2.12.1" }, "devDependencies": { "@jupyterlab/testing": "^4.1.2", "@types/jest": "^29.2.0", - "@types/sanitize-html": "^2.3.1", + "@types/sanitize-html": "^2.11.0", "jest": "^29.2.0", "rimraf": "~5.0.5", "typedoc": "~0.24.7", diff --git a/packages/cells/src/widget.ts b/packages/cells/src/widget.ts index 1f638bbd635d..d8a3479d2a93 100644 --- a/packages/cells/src/widget.ts +++ b/packages/cells/src/widget.ts @@ -204,6 +204,7 @@ export class Cell extends Widget { // For cells disable searching with CodeMirror search panel. this._editorConfig = { searchWithCM: false, ...options.editorConfig }; + this._editorExtensions = options.editorExtensions ?? []; this._placeholder = true; this._inViewport = false; this.placeholder = options.placeholder ?? true; @@ -615,7 +616,7 @@ export class Cell extends Widget { * @returns Editor options */ protected getEditorOptions(): InputArea.IOptions['editorOptions'] { - return { config: this.editorConfig }; + return { config: this.editorConfig, extensions: this._editorExtensions }; } /** @@ -694,6 +695,7 @@ export class Cell extends Widget { protected _displayChanged = new Signal(this); private _editorConfig: Record = {}; + private _editorExtensions: Extension[] = []; private _input: InputArea | null; private _inputHidden = false; private _inputWrapper: Widget | null; diff --git a/packages/console/src/widget.ts b/packages/console/src/widget.ts index adfa68028caf..79a8661f71d6 100644 --- a/packages/console/src/widget.ts +++ b/packages/console/src/widget.ts @@ -92,6 +92,11 @@ const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells'; * The data attribute added to a widget that can undo. */ const UNDOER = 'jpUndoer'; +/** + * The data attribute Whether the console interaction mimics the notebook + * or terminal keyboard shortcuts. + */ +const INTERACTION_MODE = 'jpInteractionMode'; /** * A widget containing a Jupyter console. @@ -789,6 +794,7 @@ export class CodeConsole extends Widget { * Create the options used to initialize a code cell widget. */ private _createCodeCellOptions(): CodeCell.IOptions { + const { node } = this; const contentFactory = this.contentFactory; const modelFactory = this.modelFactory; const model = modelFactory.createCodeCell({}); @@ -798,7 +804,10 @@ export class CodeConsole extends Widget { // Suppress the default "Enter" key handling. const onKeyDown = EditorView.domEventHandlers({ keydown: (event: KeyboardEvent, view: EditorView) => { - if (event.keyCode === 13) { + if ( + event.keyCode === 13 && + node.dataset[INTERACTION_MODE] === 'terminal' + ) { event.preventDefault(); return true; } diff --git a/packages/documentsearch/src/searchview.tsx b/packages/documentsearch/src/searchview.tsx index c4216ae96eb8..3e3567011219 100644 --- a/packages/documentsearch/src/searchview.tsx +++ b/packages/documentsearch/src/searchview.tsx @@ -646,15 +646,20 @@ class SearchOverlay extends React.Component {
{Object.keys(filters).map(name => { const filter = filters[name]; + + const isEnabled = !showReplace || filter.supportReplace; + // Show an alternate description, if one exists, when a filter is disabled in replace mode. + const description = isEnabled + ? filter.description + : filter.disabledDescription ?? filter.description; return ( { await this.props.onFilterChanged( name, diff --git a/packages/documentsearch/src/tokens.ts b/packages/documentsearch/src/tokens.ts index 8710111def72..3d6359763c97 100644 --- a/packages/documentsearch/src/tokens.ts +++ b/packages/documentsearch/src/tokens.ts @@ -29,6 +29,10 @@ export interface IFilter { * Filter description */ description: string; + /** + * Filter description to be used when the filter is disabled in replace mode. + */ + disabledDescription?: string; /** * Default value */ diff --git a/packages/filebrowser-extension/src/index.ts b/packages/filebrowser-extension/src/index.ts index 0c359ae42068..cae67dc98955 100644 --- a/packages/filebrowser-extension/src/index.ts +++ b/packages/filebrowser-extension/src/index.ts @@ -40,7 +40,7 @@ import { Contents } from '@jupyterlab/services'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { IStateDB } from '@jupyterlab/statedb'; import { IStatusBar } from '@jupyterlab/statusbar'; -import { ITranslator } from '@jupyterlab/translation'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { addIcon, closeIcon, @@ -62,13 +62,12 @@ import { stopIcon, textEditorIcon } from '@jupyterlab/ui-components'; -import { find, map } from '@lumino/algorithm'; +import { map } from '@lumino/algorithm'; import { CommandRegistry } from '@lumino/commands'; import { ContextMenu } from '@lumino/widgets'; const FILE_BROWSER_FACTORY = 'FileBrowser'; const FILE_BROWSER_PLUGIN_ID = '@jupyterlab/filebrowser-extension:browser'; - /** * The class name added to the filebrowser filterbox node. */ @@ -313,21 +312,50 @@ const defaultFileBrowser: JupyterFrontEndPlugin = { description: 'Provides the default file browser', provides: IDefaultFileBrowser, requires: [IFileBrowserFactory], - optional: [IRouter, JupyterFrontEnd.ITreeResolver, ILabShell], + optional: [IRouter, JupyterFrontEnd.ITreeResolver, ILabShell, ITranslator], activate: async ( app: JupyterFrontEnd, fileBrowserFactory: IFileBrowserFactory, router: IRouter | null, tree: JupyterFrontEnd.ITreeResolver | null, - labShell: ILabShell | null + labShell: ILabShell | null, + translator: ITranslator | null ): Promise => { const { commands } = app; + const trans = (translator ?? nullTranslator).load('jupyterlab'); // Manually restore and load the default file browser. const defaultBrowser = fileBrowserFactory.createFileBrowser('filebrowser', { auto: false, restore: false }); + + // Set attributes when adding the browser to the UI + defaultBrowser.node.setAttribute('role', 'region'); + defaultBrowser.node.setAttribute( + 'aria-label', + trans.__('File Browser Section') + ); + defaultBrowser.node.setAttribute('title', trans.__('File Browser')); + defaultBrowser.title.icon = folderIcon; + + // Show the current file browser shortcut in its title. + const updateBrowserTitle = () => { + const binding = app.commands.keyBindings.find( + b => b.command === CommandIDs.toggleBrowser + ); + if (binding) { + const ks = binding.keys.map(CommandRegistry.formatKeystroke).join(', '); + defaultBrowser.title.caption = trans.__('File Browser (%1)', ks); + } else { + defaultBrowser.title.caption = trans.__('File Browser'); + } + }; + updateBrowserTitle(); + app.commands.keyBindingChanged.connect(() => { + updateBrowserTitle(); + }); + void Private.restoreBrowser( defaultBrowser, commands, @@ -434,29 +462,6 @@ const browserWidget: JupyterFrontEndPlugin = { const { tracker } = factory; const trans = translator.load('jupyterlab'); - // Set attributes when adding the browser to the UI - browser.node.setAttribute('role', 'region'); - browser.node.setAttribute('aria-label', trans.__('File Browser Section')); - browser.title.icon = folderIcon; - - // Show the current file browser shortcut in its title. - const updateBrowserTitle = () => { - const binding = find( - app.commands.keyBindings, - b => b.command === CommandIDs.toggleBrowser - ); - if (binding) { - const ks = binding.keys.map(CommandRegistry.formatKeystroke).join(', '); - browser.title.caption = trans.__('File Browser (%1)', ks); - } else { - browser.title.caption = trans.__('File Browser'); - } - }; - updateBrowserTitle(); - app.commands.keyBindingChanged.connect(() => { - updateBrowserTitle(); - }); - // Toolbar toolbarRegistry.addFactory( FILE_BROWSER_FACTORY, diff --git a/packages/filebrowser/style/base.css b/packages/filebrowser/style/base.css index 8a69e1f9c1b2..9247008308ae 100644 --- a/packages/filebrowser/style/base.css +++ b/packages/filebrowser/style/base.css @@ -276,17 +276,23 @@ user-select: none; } -.jp-DirListing-itemText:focus { +.jp-DirListing-item:has(.jp-DirListing-itemText:focus-visible) { + /* Targeting `.jp-DirListing-itemText` specifically to avoid an extra outline + when it gets replaced with `jp-DirListing-editor` when editing the file name */ outline-width: 2px; outline-color: var(--jp-inverse-layout-color1); outline-style: solid; - outline-offset: 1px; + outline-offset: -4px; } -.jp-DirListing-item.jp-mod-selected .jp-DirListing-itemText:focus { +.jp-DirListing-item.jp-mod-selected:focus-within { outline-color: var(--jp-layout-color1); } +.jp-DirListing-item > .jp-DirListing-itemText:focus { + outline: 0; +} + .jp-DirListing-itemModified { flex: 0 0 125px; text-align: right; diff --git a/packages/notebook/src/searchprovider.ts b/packages/notebook/src/searchprovider.ts index c1166118bb2d..a4dd6e15d6aa 100644 --- a/packages/notebook/src/searchprovider.ts +++ b/packages/notebook/src/searchprovider.ts @@ -236,6 +236,9 @@ export class NotebookSearchProvider extends SearchProvider { output: { title: trans.__('Search Cell Outputs'), description: trans.__('Search in the cell outputs.'), + disabledDescription: trans.__( + 'Search in the cell outputs (not available when replace options are shown).' + ), default: false, supportReplace: false }, @@ -521,7 +524,7 @@ export class NotebookSearchProvider extends SearchProvider { const reply = await showDialog({ title: trans.__('Confirmation'), body: trans.__( - 'Searching outputs is expensive and requires to first rendered all outputs. Are you sure you want to search in the cell outputs?' + 'Searching outputs requires you to run all cells and render their outputs. Are you sure you want to search in the cell outputs?' ), buttons: [ Dialog.cancelButton({ label: trans.__('Cancel') }), diff --git a/packages/testing/package.json b/packages/testing/package.json index 553a37a93954..72185a0872e1 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -38,7 +38,6 @@ "@jupyterlab/coreutils": "^6.1.2", "@lumino/coreutils": "^2.1.2", "@lumino/signaling": "^2.1.2", - "child_process": "~1.0.2", "deepmerge": "^4.2.2", "fs-extra": "^10.1.0", "identity-obj-proxy": "^3.0.0", diff --git a/packages/ui-components/src/components/toolbar.tsx b/packages/ui-components/src/components/toolbar.tsx index 1089770e0c62..964c897231b9 100644 --- a/packages/ui-components/src/components/toolbar.tsx +++ b/packages/ui-components/src/components/toolbar.tsx @@ -376,7 +376,7 @@ export class ReactiveToolbar extends Toolbar { this.popupOpener.hide(); this._resizer = new Throttler(async (callTwice = false) => { await this._onResize(callTwice); - }, 300); + }, 500); } /** @@ -429,11 +429,6 @@ export class ReactiveToolbar extends Toolbar { ): boolean { const targetPosition = this._widgetPositions.get(at); const position = (targetPosition ?? 0) + offset; - this._widgetPositions.forEach((value, key) => { - if (key !== TOOLBAR_OPENER_NAME && value >= position) { - this._widgetPositions.set(key, value + 1); - } - }); return this.insertItem(position, name, widget); } @@ -465,12 +460,34 @@ export class ReactiveToolbar extends Toolbar { status = super.insertItem(j, name, widget); } - // Save the widgets position when a new widget is inserted. + // Save the widgets position when a widget is inserted or moved. if ( name !== TOOLBAR_OPENER_NAME && - this._widgetPositions.get(name) === undefined + this._widgetPositions.get(name) !== index ) { + // If the widget is inserted, set its current position as last. + const currentPosition = + this._widgetPositions.get(name) ?? this._widgetPositions.size; + + // Change the position of moved widgets. + this._widgetPositions.forEach((value, key) => { + if (key !== TOOLBAR_OPENER_NAME) { + if (value >= index && value < currentPosition) { + this._widgetPositions.set(key, value + 1); + } else if (value <= index && value > currentPosition) { + this._widgetPositions.set(key, value - 1); + } + } + }); + + // Save the new position of the widget. this._widgetPositions.set(name, index); + + // Invokes resizing to ensure correct display of items after an addition, only + // if the toolbar is rendered. + if (this.isVisible) { + void this._onResize(); + } } return status; } @@ -526,7 +543,7 @@ export class ReactiveToolbar extends Toolbar { } const toolbarWidth = this.node.clientWidth; const opener = this.popupOpener; - const openerWidth = 30; + const openerWidth = 32; // left and right padding. const toolbarPadding = 2 + 5; let width = opener.isHidden ? toolbarPadding : toolbarPadding + openerWidth; @@ -535,13 +552,27 @@ export class ReactiveToolbar extends Toolbar { .then(values => { let { width, widgetsToRemove } = values; while (widgetsToRemove.length > 0) { - // Insert the widget in the right position in the opener popup. + // Insert the widget at the right position in the opener popup, relatively + // to the saved position of the first item of the popup toolbar. + + // Get the saved position of the widget to insert. const widget = widgetsToRemove.pop() as Widget; const name = Private.nameProperty.get(widget); - width -= this._widgetWidths![name]; + width -= this._widgetWidths.get(name) || 0; const position = this._widgetPositions.get(name) ?? 0; - const index = - opener.widgetCount() + position - this._widgetPositions.size; + + // Get the saved position of the first item in the popup toolbar. + // If there is no widget, set the value at last item. + let openerFirstIndex = this._widgetPositions.size; + const openerFirst = opener.widgetAt(0); + if (openerFirst) { + const openerFirstName = Private.nameProperty.get(openerFirst); + openerFirstIndex = + this._widgetPositions.get(openerFirstName) ?? openerFirstIndex; + } + + // Insert the widget in the popup toolbar. + const index = position - openerFirstIndex; opener.insertWidget(index, widget); } if (opener.widgetCount() > 0) { @@ -606,17 +637,19 @@ export class ReactiveToolbar extends Toolbar { let index = 0; while (index < toIndex) { const widget = layout.widgets[index]; + const name = Private.nameProperty.get(widget); // Compute the widget size only if // - the zoom has changed. // - the widget size has not been computed yet. let widgetWidth: number; if (this._zoomChanged) { - widgetWidth = await this._saveWidgetWidth(widget); + widgetWidth = await this._saveWidgetWidth(name, widget); } else { // The widget widths can be 0px if it has been added to the toolbar but // not rendered, this is why we must use '||' instead of '??'. widgetWidth = - this._getWidgetWidth(widget) || (await this._saveWidgetWidth(widget)); + this._getWidgetWidth(widget) || + (await this._saveWidgetWidth(name, widget)); } width += widgetWidth; if ( @@ -626,12 +659,18 @@ export class ReactiveToolbar extends Toolbar { ) { width += openerWidth; } - if (width > toolbarWidth) { + // Remove the widget if it is out of the toolbar or incorrectly positioned. + // Incorrect positioning can occur when the widget is added after the toolbar + // has been rendered and should be in the popup. E.g. debugger icon with a + // narrow notebook toolbar. + if ( + width > toolbarWidth || + (this._widgetPositions.get(name) ?? 0) > index + ) { widgetsToRemove.push(widget); } index++; } - this._zoomChanged = false; return { width: width, @@ -639,27 +678,28 @@ export class ReactiveToolbar extends Toolbar { }; } - private async _saveWidgetWidth(widget: Widget): Promise { + private async _saveWidgetWidth( + name: string, + widget: Widget + ): Promise { if (widget instanceof ReactWidget) { await widget.renderPromise; } - const widgetName = Private.nameProperty.get(widget); - const widgetWidth = widget.hasClass(TOOLBAR_SPACER_CLASS) ? 2 : widget.node.clientWidth; - this._widgetWidths![widgetName] = widgetWidth; + this._widgetWidths.set(name, widgetWidth); return widgetWidth; } private _getWidgetWidth(widget: Widget): number { const widgetName = Private.nameProperty.get(widget); - return this._widgetWidths![widgetName]; + return this._widgetWidths.get(widgetName) || 0; } protected readonly popupOpener: ToolbarPopupOpener = new ToolbarPopupOpener(); - private readonly _widgetWidths: { [key: string]: number } = {}; private readonly _resizer: Throttler; + private readonly _widgetWidths = new Map(); private _widgetPositions = new Map(); // The zoom property is not the real browser zoom, but a value proportional to // the zoom, which is modified when the zoom changes. diff --git a/yarn.lock b/yarn.lock index 0d6f633a8047..101b5994dd6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2291,11 +2291,11 @@ __metadata: "@lumino/widgets": ^2.3.1 "@types/jest": ^29.2.0 "@types/react": ^18.0.26 - "@types/sanitize-html": ^2.3.1 + "@types/sanitize-html": ^2.11.0 jest: ^29.2.0 react: ^18.2.0 rimraf: ~5.0.5 - sanitize-html: ~2.7.3 + sanitize-html: ~2.12.1 typedoc: ~0.24.7 typescript: ~5.1.6 languageName: unknown @@ -2375,7 +2375,6 @@ __metadata: "@types/prettier": ~2.6.0 "@yarnpkg/core": ^3.0.0 "@yarnpkg/parsers": ^2.0.0 - child_process: ~1.0.2 commander: ^9.4.1 crypto: ~1.0.1 dependency-graph: ^0.11.0 @@ -4818,7 +4817,6 @@ __metadata: "@types/jest": ^29.2.0 "@types/node": ^18.11.18 "@types/node-fetch": ^2.6.2 - child_process: ~1.0.2 deepmerge: ^4.2.2 fs-extra: ^10.1.0 identity-obj-proxy: ^3.0.0 @@ -7151,12 +7149,12 @@ __metadata: languageName: node linkType: hard -"@types/sanitize-html@npm:^2.3.1": - version: 2.8.1 - resolution: "@types/sanitize-html@npm:2.8.1" +"@types/sanitize-html@npm:^2.11.0": + version: 2.11.0 + resolution: "@types/sanitize-html@npm:2.11.0" dependencies: htmlparser2: ^8.0.0 - checksum: 9c07d3a9d925e291472f74b097fb179b32659ea01834f728887811e5fc75cf2b17d844e32e97c0e583eba993af86a3f8250c82c8fa3152abf9ff2a8582972906 + checksum: a901d55d31cd946a7fce0130cc7cf6bcf56602af9c87291be77d8149c60e7afc47c83ca74c67c2d84e6ba029fe9bbd6f14f89a8cb30fbd185766eebc5722c251 languageName: node linkType: hard @@ -9192,13 +9190,6 @@ __metadata: languageName: node linkType: hard -"child_process@npm:~1.0.2": - version: 1.0.2 - resolution: "child_process@npm:1.0.2" - checksum: bd814d82bc8c6e85ed6fb157878978121cd03b5296c09f6135fa3d081fd9a6a617a6d509c50397711df713af403331241a9c0397a7fad30672051485e156c2a1 - languageName: node - linkType: hard - "chokidar@npm:^3.4.0": version: 3.5.3 resolution: "chokidar@npm:3.5.3" @@ -12946,7 +12937,7 @@ __metadata: languageName: node linkType: hard -"htmlparser2@npm:^6.0.0, htmlparser2@npm:^6.1.0": +"htmlparser2@npm:^6.1.0": version: 6.1.0 resolution: "htmlparser2@npm:6.1.0" dependencies: @@ -18692,17 +18683,17 @@ __metadata: languageName: node linkType: hard -"sanitize-html@npm:~2.7.3": - version: 2.7.3 - resolution: "sanitize-html@npm:2.7.3" +"sanitize-html@npm:~2.12.1": + version: 2.12.1 + resolution: "sanitize-html@npm:2.12.1" dependencies: deepmerge: ^4.2.2 escape-string-regexp: ^4.0.0 - htmlparser2: ^6.0.0 + htmlparser2: ^8.0.0 is-plain-object: ^5.0.0 parse-srcset: ^1.0.2 postcss: ^8.3.11 - checksum: 2399d1fdbbc3a263fb413c1fe1971b3dc2b51abc6cc5cb49490624539d1c57a8fe31e2b21408c118e2a957f4e673e3169b1f9a5807654408f17b130a9d78aed7 + checksum: fb96ea7170d51b5af2607f5cfd84464c78fc6f47e339407f55783e781c6a0288a8d40bbf97ea6a8758924ba9b2d33dcc4846bb94caacacd90d7f2de10ed8541a languageName: node linkType: hard