Skip to content

Commit fd45f31

Browse files
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>
1 parent 9863625 commit fd45f31

File tree

11 files changed

+307
-1
lines changed

11 files changed

+307
-1
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"title": "File Browser Widget - File Actions",
3+
"description": "File Browser widget - File Actions settings.",
4+
"jupyter.lab.toolbars": {
5+
"FileBrowser": [{ "name": "fileActions", "rank": 0 }]
6+
},
7+
"properties": {},
8+
"additionalProperties": false,
9+
"type": "object"
10+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) Jupyter Development Team.
2+
// Distributed under the terms of the Modified BSD License.
3+
4+
import {
5+
CommandToolbarButtonComponent,
6+
ReactWidget,
7+
UseSignal,
8+
} from '@jupyterlab/apputils';
9+
10+
import { FileBrowser } from '@jupyterlab/filebrowser';
11+
12+
import { ITranslator } from '@jupyterlab/translation';
13+
14+
import { CommandRegistry } from '@lumino/commands';
15+
16+
import { ISignal } from '@lumino/signaling';
17+
18+
import React from 'react';
19+
20+
/**
21+
* A React component to display the list of command toolbar buttons.
22+
*
23+
*/
24+
const Commands = ({
25+
commands,
26+
browser,
27+
translator,
28+
}: {
29+
commands: CommandRegistry;
30+
browser: FileBrowser;
31+
translator: ITranslator;
32+
}): JSX.Element => {
33+
const trans = translator.load('notebook');
34+
const selection = Array.from(browser.selectedItems());
35+
const oneFolder = selection.some((item) => item.type === 'directory');
36+
const multipleFiles =
37+
selection.filter((item) => item.type === 'file').length > 1;
38+
if (selection.length === 0) {
39+
return <div>{trans.__('Select items to perform actions on them.')}</div>;
40+
} else {
41+
const buttons = ['delete'];
42+
if (!oneFolder) {
43+
buttons.unshift('duplicate');
44+
if (!multipleFiles) {
45+
buttons.unshift('rename');
46+
}
47+
buttons.unshift('download');
48+
buttons.unshift('open');
49+
} else if (selection.length === 1) {
50+
buttons.unshift('rename');
51+
}
52+
53+
return (
54+
<>
55+
{buttons.map((action) => (
56+
<CommandToolbarButtonComponent
57+
key={action}
58+
commands={commands}
59+
id={`filebrowser:${action}`}
60+
args={{ toolbar: true }}
61+
icon={undefined}
62+
/>
63+
))}
64+
</>
65+
);
66+
}
67+
};
68+
69+
/**
70+
* A React component to display the file action buttons in the file browser toolbar.
71+
*
72+
* @param translator The Translation service
73+
*/
74+
const FileActions = ({
75+
commands,
76+
browser,
77+
selectionChanged,
78+
translator,
79+
}: {
80+
commands: CommandRegistry;
81+
browser: FileBrowser;
82+
selectionChanged: ISignal<FileBrowser, void>;
83+
translator: ITranslator;
84+
}): JSX.Element => {
85+
return (
86+
<UseSignal signal={selectionChanged} shouldUpdate={() => true}>
87+
{(): JSX.Element => (
88+
<Commands
89+
commands={commands}
90+
browser={browser}
91+
translator={translator}
92+
/>
93+
)}
94+
</UseSignal>
95+
);
96+
};
97+
98+
/**
99+
* A namespace for FileActionsComponent statics.
100+
*/
101+
export namespace FileActionsComponent {
102+
/**
103+
* Create a new FileActionsComponent
104+
*
105+
* @param translator The translator
106+
*/
107+
export const create = ({
108+
commands,
109+
browser,
110+
selectionChanged,
111+
translator,
112+
}: {
113+
commands: CommandRegistry;
114+
browser: FileBrowser;
115+
selectionChanged: ISignal<FileBrowser, void>;
116+
translator: ITranslator;
117+
}): ReactWidget => {
118+
const widget = ReactWidget.create(
119+
<FileActions
120+
commands={commands}
121+
browser={browser}
122+
selectionChanged={selectionChanged}
123+
translator={translator}
124+
/>
125+
);
126+
widget.addClass('jp-FileActions');
127+
return widget;
128+
};
129+
}

packages/tree-extension/src/index.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,14 @@ import {
3939
runningIcon,
4040
} from '@jupyterlab/ui-components';
4141

42+
import { Signal } from '@lumino/signaling';
43+
4244
import { Menu, MenuBar } from '@lumino/widgets';
4345

4446
import { NotebookTreeWidget, INotebookTree } from '@jupyter-notebook/tree';
4547

48+
import { FileActionsComponent } from './fileactions';
49+
4650
/**
4751
* The file browser factory.
4852
*/
@@ -119,6 +123,54 @@ const createNew: JupyterFrontEndPlugin<void> = {
119123
},
120124
};
121125

126+
/**
127+
* A plugin to add file browser actions to the file browser toolbar.
128+
*/
129+
const fileActions: JupyterFrontEndPlugin<void> = {
130+
id: '@jupyter-notebook/tree-extension:file-actions',
131+
autoStart: true,
132+
requires: [IDefaultFileBrowser, IToolbarWidgetRegistry, ITranslator],
133+
activate: (
134+
app: JupyterFrontEnd,
135+
browser: IDefaultFileBrowser,
136+
toolbarRegistry: IToolbarWidgetRegistry,
137+
translator: ITranslator
138+
) => {
139+
// TODO: use upstream signal when available to detect selection changes
140+
// https://github.com/jupyterlab/jupyterlab/issues/14598
141+
const selectionChanged = new Signal<FileBrowser, void>(browser);
142+
const methods = [
143+
'_selectItem',
144+
'_handleMultiSelect',
145+
'handleFileSelect',
146+
] as const;
147+
methods.forEach((method: (typeof methods)[number]) => {
148+
const original = browser['listing'][method];
149+
browser['listing'][method] = (...args: any[]) => {
150+
original.call(browser['listing'], ...args);
151+
selectionChanged.emit(void 0);
152+
};
153+
});
154+
155+
// Create a toolbar item that adds buttons to the file browser toolbar
156+
// to perform actions on the files
157+
toolbarRegistry.addFactory(
158+
FILE_BROWSER_FACTORY,
159+
'fileActions',
160+
(browser: FileBrowser) => {
161+
const { commands } = app;
162+
const fileActions = FileActionsComponent.create({
163+
commands,
164+
browser,
165+
selectionChanged,
166+
translator,
167+
});
168+
return fileActions;
169+
}
170+
);
171+
},
172+
};
173+
122174
/**
123175
* Plugin to load the default plugins that are loaded on all the Notebook pages
124176
* (tree, edit, view, etc.) so they are visible in the settings editor.
@@ -238,7 +290,6 @@ const notebookTreeWidget: JupyterFrontEndPlugin<INotebookTree> = {
238290
nbTreeWidget.tabBar.addTab(browser.title);
239291
nbTreeWidget.tabsMovable = false;
240292

241-
// Toolbar
242293
toolbarRegistry.addFactory(
243294
FILE_BROWSER_FACTORY,
244295
'uploader',
@@ -331,6 +382,7 @@ const notebookTreeWidget: JupyterFrontEndPlugin<INotebookTree> = {
331382
*/
332383
const plugins: JupyterFrontEndPlugin<any>[] = [
333384
createNew,
385+
fileActions,
334386
loadPlugins,
335387
openFileBrowser,
336388
notebookTreeWidget,

packages/tree-extension/style/base.css

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,34 @@
2424
.jp-FileBrowser-filterBox input {
2525
line-height: 24px;
2626
}
27+
28+
.jp-DirListing-content .jp-DirListing-checkboxWrapper {
29+
visibility: visible;
30+
}
31+
32+
/* Action buttons */
33+
34+
.jp-FileBrowser-toolbar > .jp-FileActions.jp-Toolbar-item {
35+
display: flex;
36+
flex-direction: row;
37+
}
38+
39+
.jp-FileActions .jp-ToolbarButtonComponent-icon {
40+
display: none;
41+
}
42+
43+
.jp-FileActions .jp-ToolbarButtonComponent[data-command='filebrowser:delete'] {
44+
background-color: var(--jp-error-color1);
45+
}
46+
47+
.jp-FileActions
48+
.jp-ToolbarButtonComponent[data-command='filebrowser:delete']
49+
.jp-ToolbarButtonComponent-label {
50+
color: var(--jp-ui-inverse-font-color1);
51+
}
52+
53+
.jp-FileBrowser-toolbar .jp-FileActions .jp-ToolbarButtonComponent {
54+
border: solid 1px var(--jp-border-color2);
55+
margin: 1px;
56+
min-height: 100%;
57+
}

ui-tests/test/filebrowser.spec.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) Jupyter Development Team.
2+
// Distributed under the terms of the Modified BSD License.
3+
4+
import path from 'path';
5+
6+
import { expect } from '@playwright/test';
7+
8+
import { test } from './fixtures';
9+
10+
test.describe('File Browser', () => {
11+
test.beforeEach(async ({ page, tmpPath }) => {
12+
await page.contents.uploadFile(
13+
path.resolve(__dirname, './notebooks/empty.ipynb'),
14+
`${tmpPath}/empty.ipynb`
15+
);
16+
await page.contents.createDirectory(`${tmpPath}/folder1`);
17+
await page.contents.createDirectory(`${tmpPath}/folder2`);
18+
});
19+
20+
test('Select one folder', async ({ page, tmpPath }) => {
21+
await page.filebrowser.refresh();
22+
23+
await page.getByText('folder1').last().click();
24+
25+
const toolbar = page.getByRole('navigation');
26+
27+
expect(toolbar.getByText('Rename')).toBeVisible();
28+
expect(toolbar.getByText('Delete')).toBeVisible();
29+
});
30+
31+
test('Select one file', async ({ page, tmpPath }) => {
32+
await page.filebrowser.refresh();
33+
34+
await page.getByText('empty.ipynb').last().click();
35+
36+
const toolbar = page.getByRole('navigation');
37+
38+
['Rename', 'Delete', 'Open', 'Download', 'Delete'].forEach(async (text) => {
39+
expect(toolbar.getByText(text)).toBeVisible();
40+
});
41+
});
42+
43+
test('Select files and folders', async ({ page, tmpPath }) => {
44+
await page.filebrowser.refresh();
45+
46+
await page.keyboard.down('Control');
47+
await page.getByText('folder1').last().click();
48+
await page.getByText('folder2').last().click();
49+
await page.getByText('empty.ipynb').last().click();
50+
51+
const toolbar = page.getByRole('navigation');
52+
53+
expect(toolbar.getByText('Rename')).toBeHidden();
54+
expect(toolbar.getByText('Open')).toBeHidden();
55+
expect(toolbar.getByText('Delete')).toBeVisible();
56+
});
57+
58+
test('Select files and open', async ({ page, tmpPath }) => {
59+
// upload an additional notebook
60+
await page.contents.uploadFile(
61+
path.resolve(__dirname, './notebooks/simple.ipynb'),
62+
`${tmpPath}/simple.ipynb`
63+
);
64+
await page.filebrowser.refresh();
65+
66+
await page.keyboard.down('Control');
67+
await page.getByText('simple.ipynb').last().click();
68+
await page.getByText('empty.ipynb').last().click();
69+
70+
const toolbar = page.getByRole('navigation');
71+
72+
const [nb1, nb2] = await Promise.all([
73+
page.waitForEvent('popup'),
74+
page.waitForEvent('popup'),
75+
toolbar.getByText('Open').last().click(),
76+
]);
77+
78+
await nb1.waitForLoadState();
79+
await nb1.close();
80+
81+
await nb2.waitForLoadState();
82+
await nb2.close();
83+
});
84+
});
697 Bytes
Loading
723 Bytes
Loading
1.04 KB
Loading
1.05 KB
Loading
1.8 KB
Loading

0 commit comments

Comments
 (0)