Add file browser actions to the file browser toolbar (#6888)

* Add component to provide file browser actions

* Fix usesignal

* Lint

* Flex display

* CSS fixes

* Undo unrelated change

* Patch methods directly

* Fix folder selection

* Update Playwright Snapshots

* Update Playwright Snapshots

* Add link to the upstream issue

* Add UI tests

* Lint

* Tweak delete button color

* Move to a separate plugin

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Jeremy Tuloup 2023-05-29 16:45:53 +02:00 committed by GitHub
parent 9863625809
commit fd45f31f98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 307 additions and 1 deletions

View File

@ -0,0 +1,10 @@
{
"title": "File Browser Widget - File Actions",
"description": "File Browser widget - File Actions settings.",
"jupyter.lab.toolbars": {
"FileBrowser": [{ "name": "fileActions", "rank": 0 }]
},
"properties": {},
"additionalProperties": false,
"type": "object"
}

View File

@ -0,0 +1,129 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import {
CommandToolbarButtonComponent,
ReactWidget,
UseSignal,
} from '@jupyterlab/apputils';
import { FileBrowser } from '@jupyterlab/filebrowser';
import { ITranslator } from '@jupyterlab/translation';
import { CommandRegistry } from '@lumino/commands';
import { ISignal } from '@lumino/signaling';
import React from 'react';
/**
* A React component to display the list of command toolbar buttons.
*
*/
const Commands = ({
commands,
browser,
translator,
}: {
commands: CommandRegistry;
browser: FileBrowser;
translator: ITranslator;
}): JSX.Element => {
const trans = translator.load('notebook');
const selection = Array.from(browser.selectedItems());
const oneFolder = selection.some((item) => item.type === 'directory');
const multipleFiles =
selection.filter((item) => item.type === 'file').length > 1;
if (selection.length === 0) {
return <div>{trans.__('Select items to perform actions on them.')}</div>;
} else {
const buttons = ['delete'];
if (!oneFolder) {
buttons.unshift('duplicate');
if (!multipleFiles) {
buttons.unshift('rename');
}
buttons.unshift('download');
buttons.unshift('open');
} else if (selection.length === 1) {
buttons.unshift('rename');
}
return (
<>
{buttons.map((action) => (
<CommandToolbarButtonComponent
key={action}
commands={commands}
id={`filebrowser:${action}`}
args={{ toolbar: true }}
icon={undefined}
/>
))}
</>
);
}
};
/**
* A React component to display the file action buttons in the file browser toolbar.
*
* @param translator The Translation service
*/
const FileActions = ({
commands,
browser,
selectionChanged,
translator,
}: {
commands: CommandRegistry;
browser: FileBrowser;
selectionChanged: ISignal<FileBrowser, void>;
translator: ITranslator;
}): JSX.Element => {
return (
<UseSignal signal={selectionChanged} shouldUpdate={() => true}>
{(): JSX.Element => (
<Commands
commands={commands}
browser={browser}
translator={translator}
/>
)}
</UseSignal>
);
};
/**
* A namespace for FileActionsComponent statics.
*/
export namespace FileActionsComponent {
/**
* Create a new FileActionsComponent
*
* @param translator The translator
*/
export const create = ({
commands,
browser,
selectionChanged,
translator,
}: {
commands: CommandRegistry;
browser: FileBrowser;
selectionChanged: ISignal<FileBrowser, void>;
translator: ITranslator;
}): ReactWidget => {
const widget = ReactWidget.create(
<FileActions
commands={commands}
browser={browser}
selectionChanged={selectionChanged}
translator={translator}
/>
);
widget.addClass('jp-FileActions');
return widget;
};
}

View File

@ -39,10 +39,14 @@ import {
runningIcon,
} from '@jupyterlab/ui-components';
import { Signal } from '@lumino/signaling';
import { Menu, MenuBar } from '@lumino/widgets';
import { NotebookTreeWidget, INotebookTree } from '@jupyter-notebook/tree';
import { FileActionsComponent } from './fileactions';
/**
* The file browser factory.
*/
@ -119,6 +123,54 @@ const createNew: JupyterFrontEndPlugin<void> = {
},
};
/**
* A plugin to add file browser actions to the file browser toolbar.
*/
const fileActions: JupyterFrontEndPlugin<void> = {
id: '@jupyter-notebook/tree-extension:file-actions',
autoStart: true,
requires: [IDefaultFileBrowser, IToolbarWidgetRegistry, ITranslator],
activate: (
app: JupyterFrontEnd,
browser: IDefaultFileBrowser,
toolbarRegistry: IToolbarWidgetRegistry,
translator: ITranslator
) => {
// TODO: use upstream signal when available to detect selection changes
// https://github.com/jupyterlab/jupyterlab/issues/14598
const selectionChanged = new Signal<FileBrowser, void>(browser);
const methods = [
'_selectItem',
'_handleMultiSelect',
'handleFileSelect',
] as const;
methods.forEach((method: (typeof methods)[number]) => {
const original = browser['listing'][method];
browser['listing'][method] = (...args: any[]) => {
original.call(browser['listing'], ...args);
selectionChanged.emit(void 0);
};
});
// Create a toolbar item that adds buttons to the file browser toolbar
// to perform actions on the files
toolbarRegistry.addFactory(
FILE_BROWSER_FACTORY,
'fileActions',
(browser: FileBrowser) => {
const { commands } = app;
const fileActions = FileActionsComponent.create({
commands,
browser,
selectionChanged,
translator,
});
return fileActions;
}
);
},
};
/**
* Plugin to load the default plugins that are loaded on all the Notebook pages
* (tree, edit, view, etc.) so they are visible in the settings editor.
@ -238,7 +290,6 @@ const notebookTreeWidget: JupyterFrontEndPlugin<INotebookTree> = {
nbTreeWidget.tabBar.addTab(browser.title);
nbTreeWidget.tabsMovable = false;
// Toolbar
toolbarRegistry.addFactory(
FILE_BROWSER_FACTORY,
'uploader',
@ -331,6 +382,7 @@ const notebookTreeWidget: JupyterFrontEndPlugin<INotebookTree> = {
*/
const plugins: JupyterFrontEndPlugin<any>[] = [
createNew,
fileActions,
loadPlugins,
openFileBrowser,
notebookTreeWidget,

View File

@ -24,3 +24,34 @@
.jp-FileBrowser-filterBox input {
line-height: 24px;
}
.jp-DirListing-content .jp-DirListing-checkboxWrapper {
visibility: visible;
}
/* Action buttons */
.jp-FileBrowser-toolbar > .jp-FileActions.jp-Toolbar-item {
display: flex;
flex-direction: row;
}
.jp-FileActions .jp-ToolbarButtonComponent-icon {
display: none;
}
.jp-FileActions .jp-ToolbarButtonComponent[data-command='filebrowser:delete'] {
background-color: var(--jp-error-color1);
}
.jp-FileActions
.jp-ToolbarButtonComponent[data-command='filebrowser:delete']
.jp-ToolbarButtonComponent-label {
color: var(--jp-ui-inverse-font-color1);
}
.jp-FileBrowser-toolbar .jp-FileActions .jp-ToolbarButtonComponent {
border: solid 1px var(--jp-border-color2);
margin: 1px;
min-height: 100%;
}

View File

@ -0,0 +1,84 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import path from 'path';
import { expect } from '@playwright/test';
import { test } from './fixtures';
test.describe('File Browser', () => {
test.beforeEach(async ({ page, tmpPath }) => {
await page.contents.uploadFile(
path.resolve(__dirname, './notebooks/empty.ipynb'),
`${tmpPath}/empty.ipynb`
);
await page.contents.createDirectory(`${tmpPath}/folder1`);
await page.contents.createDirectory(`${tmpPath}/folder2`);
});
test('Select one folder', async ({ page, tmpPath }) => {
await page.filebrowser.refresh();
await page.getByText('folder1').last().click();
const toolbar = page.getByRole('navigation');
expect(toolbar.getByText('Rename')).toBeVisible();
expect(toolbar.getByText('Delete')).toBeVisible();
});
test('Select one file', async ({ page, tmpPath }) => {
await page.filebrowser.refresh();
await page.getByText('empty.ipynb').last().click();
const toolbar = page.getByRole('navigation');
['Rename', 'Delete', 'Open', 'Download', 'Delete'].forEach(async (text) => {
expect(toolbar.getByText(text)).toBeVisible();
});
});
test('Select files and folders', async ({ page, tmpPath }) => {
await page.filebrowser.refresh();
await page.keyboard.down('Control');
await page.getByText('folder1').last().click();
await page.getByText('folder2').last().click();
await page.getByText('empty.ipynb').last().click();
const toolbar = page.getByRole('navigation');
expect(toolbar.getByText('Rename')).toBeHidden();
expect(toolbar.getByText('Open')).toBeHidden();
expect(toolbar.getByText('Delete')).toBeVisible();
});
test('Select files and open', async ({ page, tmpPath }) => {
// upload an additional notebook
await page.contents.uploadFile(
path.resolve(__dirname, './notebooks/simple.ipynb'),
`${tmpPath}/simple.ipynb`
);
await page.filebrowser.refresh();
await page.keyboard.down('Control');
await page.getByText('simple.ipynb').last().click();
await page.getByText('empty.ipynb').last().click();
const toolbar = page.getByRole('navigation');
const [nb1, nb2] = await Promise.all([
page.waitForEvent('popup'),
page.waitForEvent('popup'),
toolbar.getByText('Open').last().click(),
]);
await nb1.waitForLoadState();
await nb1.close();
await nb2.waitForLoadState();
await nb2.close();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB