@jupyterlab/filebrowser
Version:
JupyterLab - FileBrowser Widget
1,332 lines (1,331 loc) • 115 kB
JavaScript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import { Dialog, DOMUtils, showDialog, showErrorMessage } from '@jupyterlab/apputils';
import { PageConfig, PathExt, Time } from '@jupyterlab/coreutils';
import { isValidFileName, renameFile } from '@jupyterlab/docmanager';
import { DocumentRegistry } from '@jupyterlab/docregistry';
import { nullTranslator } from '@jupyterlab/translation';
import { caretDownIcon, caretUpIcon, classes, LabIcon } from '@jupyterlab/ui-components';
import { ArrayExt, filter, StringExt } from '@lumino/algorithm';
import { MimeData, PromiseDelegate } from '@lumino/coreutils';
import { ElementExt } from '@lumino/domutils';
import { DisposableDelegate } from '@lumino/disposable';
import { Drag } from '@lumino/dragdrop';
import { MessageLoop } from '@lumino/messaging';
import { Signal } from '@lumino/signaling';
import { h, VirtualDOM } from '@lumino/virtualdom';
import { Widget } from '@lumino/widgets';
/**
* The class name added to DirListing widget.
*/
const DIR_LISTING_CLASS = 'jp-DirListing';
/**
* The class name added to a dir listing header node.
*/
const HEADER_CLASS = 'jp-DirListing-header';
/**
* The class name added to a dir listing list header cell.
*/
const HEADER_ITEM_CLASS = 'jp-DirListing-headerItem';
/**
* The class name added to a header cell text node.
*/
const HEADER_ITEM_TEXT_CLASS = 'jp-DirListing-headerItemText';
/**
* The class name added to a header cell icon node.
*/
const HEADER_ITEM_ICON_CLASS = 'jp-DirListing-headerItemIcon';
/**
* The class name added to the dir listing content node.
*/
const CONTENT_CLASS = 'jp-DirListing-content';
/**
* The class name added to dir listing content item.
*/
const ITEM_CLASS = 'jp-DirListing-item';
/**
* The class name added to the listing item text cell.
*/
const ITEM_TEXT_CLASS = 'jp-DirListing-itemText';
/**
* The class name added to the listing item text cell.
*/
const ITEM_NAME_COLUMN_CLASS = 'jp-DirListing-itemName';
/**
* The class name added to the listing item icon cell.
*/
const ITEM_ICON_CLASS = 'jp-DirListing-itemIcon';
/**
* The class name added to the listing item modified cell.
*/
const ITEM_MODIFIED_CLASS = 'jp-DirListing-itemModified';
/**
* The class name added to the listing item file size cell.
*/
const ITEM_FILE_SIZE_CLASS = 'jp-DirListing-itemFileSize';
/**
* The class name added to the label element that wraps each item's checkbox and
* the header's check-all checkbox.
*/
const CHECKBOX_WRAPPER_CLASS = 'jp-DirListing-checkboxWrapper';
/**
* The class name added to the dir listing editor node.
*/
const EDITOR_CLASS = 'jp-DirListing-editor';
/**
* The class name added to the name column header cell.
*/
const NAME_ID_CLASS = 'jp-id-name';
/**
* The class name added to the modified column header cell.
*/
const MODIFIED_ID_CLASS = 'jp-id-modified';
/**
* The class name added to the file size column header cell.
*/
const FILE_SIZE_ID_CLASS = 'jp-id-filesize';
/**
* The mime type for a contents drag object.
*/
const CONTENTS_MIME = 'application/x-jupyter-icontents';
/**
* The mime type for a rich contents drag object.
*/
const CONTENTS_MIME_RICH = 'application/x-jupyter-icontentsrich';
/**
* The class name added to drop targets.
*/
const DROP_TARGET_CLASS = 'jp-mod-dropTarget';
/**
* The class name added to selected rows.
*/
const SELECTED_CLASS = 'jp-mod-selected';
/**
* The class name added to drag state icons to add space between the icon and the file name
*/
const DRAG_ICON_CLASS = 'jp-DragIcon';
/**
* The class name added to column resize handle.
*/
const RESIZE_HANDLE_CLASS = 'jp-DirListing-resizeHandle';
/**
* The class name added to the widget when there are items on the clipboard.
*/
const CLIPBOARD_CLASS = 'jp-mod-clipboard';
/**
* The class name added to cut rows.
*/
const CUT_CLASS = 'jp-mod-cut';
/**
* The class name added when there are more than one selected rows.
*/
const MULTI_SELECTED_CLASS = 'jp-mod-multiSelected';
/**
* The class name added to indicate running notebook.
*/
const RUNNING_CLASS = 'jp-mod-running';
/**
* The class name added to indicate the active element.
*/
const ACTIVE_CLASS = 'jp-mod-active';
/**
* The class name added for a descending sort.
*/
const DESCENDING_CLASS = 'jp-mod-descending';
/**
* The maximum duration between two key presses when selecting files by prefix.
*/
const PREFIX_APPEND_DURATION = 1000;
/**
* The default width of the resize handle.
*/
const DEFAULT_HANDLE_WIDTH = 5;
/**
* The threshold in pixels to start a drag event.
*/
const DRAG_THRESHOLD = 5;
/**
* A boolean indicating whether the platform is Mac.
*/
const IS_MAC = !!navigator.platform.match(/Mac/i);
/**
* The factory MIME type supported by lumino dock panels.
*/
const FACTORY_MIME = 'application/vnd.lumino.widget-factory';
/**
* A widget which hosts a file list area.
*/
export class DirListing extends Widget {
/**
* Construct a new file browser directory listing widget.
*
* @param options The constructor options
*/
constructor(options) {
super({
node: (options.renderer || DirListing.defaultRenderer).createNode()
});
this._items = [];
this._sortedItems = [];
this._sortState = {
direction: 'ascending',
key: 'name'
};
this._onItemOpened = new Signal(this);
this._drag = null;
this._dragData = null;
this._resizeData = null;
this._selectTimer = -1;
this._isCut = false;
this._prevPath = '';
this._clipboard = [];
this._softSelection = '';
this.selection = Object.create(null);
this._searchPrefix = '';
this._searchPrefixTimer = -1;
this._inRename = false;
this._isDirty = false;
this._hiddenColumns = new Set();
this._columnSizes = {
name: null,
file_size: null,
is_selected: null,
last_modified: null
};
this._sortNotebooksFirst = false;
this._allowSingleClick = false;
// _focusIndex should never be set outside the range [0, this._items.length - 1]
this._focusIndex = 0;
this._allUploaded = new Signal(this);
this._width = null;
this._state = null;
this._contentScrollbarWidth = 0;
this._contentSizeObserver = new ResizeObserver(this._onContentResize.bind(this));
this._paddingWidth = 0;
this._handleWidth = DEFAULT_HANDLE_WIDTH;
this._lastRenderedState = new WeakMap();
this.addClass(DIR_LISTING_CLASS);
this.translator = options.translator || nullTranslator;
this._trans = this.translator.load('jupyterlab');
this._model = options.model;
this._model.fileChanged.connect(this._onFileChanged, this);
this._model.refreshed.connect(this._onModelRefreshed, this);
this._model.pathChanged.connect(this._onPathChanged, this);
this._editNode = document.createElement('input');
this._editNode.className = EDITOR_CLASS;
this._manager = this._model.manager;
this._renderer = options.renderer || DirListing.defaultRenderer;
this._state = options.state || null;
// Get the width of the "modified" column
this._updateModifiedSize(this.node);
const headerNode = DOMUtils.findElement(this.node, HEADER_CLASS);
// hide the file size column by default
this._hiddenColumns.add('file_size');
this._renderer.populateHeaderNode(headerNode, this.translator, this._hiddenColumns, this._columnSizes);
this._manager.activateRequested.connect(this._onActivateRequested, this);
}
/**
* Dispose of the resources held by the directory listing.
*/
dispose() {
this._items.length = 0;
this._sortedItems.length = 0;
this._clipboard.length = 0;
super.dispose();
}
/**
* Get the model used by the listing.
*/
get model() {
return this._model;
}
/**
* Get the dir listing header node.
*
* #### Notes
* This is the node which holds the header cells.
*
* Modifying this node directly can lead to undefined behavior.
*/
get headerNode() {
return DOMUtils.findElement(this.node, HEADER_CLASS);
}
/**
* Get the dir listing content node.
*
* #### Notes
* This is the node which holds the item nodes.
*
* Modifying this node directly can lead to undefined behavior.
*/
get contentNode() {
return DOMUtils.findElement(this.node, CONTENT_CLASS);
}
/**
* The renderer instance used by the directory listing.
*/
get renderer() {
return this._renderer;
}
/**
* The current sort state.
*/
get sortState() {
return this._sortState;
}
/**
* A signal fired when an item is opened.
*/
get onItemOpened() {
return this._onItemOpened;
}
/**
* Create an iterator over the listing's selected items.
*
* @returns A new iterator over the listing's selected items.
*/
selectedItems() {
const items = this._sortedItems;
return filter(items, item => this.selection[item.path]);
}
/**
* Create an iterator over the listing's sorted items.
*
* @returns A new iterator over the listing's sorted items.
*/
sortedItems() {
return this._sortedItems[Symbol.iterator]();
}
/**
* Sort the items using a sort condition.
*/
sort(state) {
this._sortedItems = Private.sort(this.model.items(), state, this._sortNotebooksFirst, this.translator);
this._sortState = state;
this.update();
}
/**
* Rename the first currently selected item.
*
* @returns A promise that resolves with the new name of the item.
*/
rename() {
return this._doRename();
}
/**
* Cut the selected items.
*/
cut() {
this._isCut = true;
this._copy();
this.update();
}
/**
* Copy the selected items.
*/
copy() {
this._copy();
}
/**
* Paste the items from the clipboard.
*
* @returns A promise that resolves when the operation is complete.
*/
paste() {
if (!this._clipboard.length) {
this._isCut = false;
return Promise.resolve(undefined);
}
const basePath = this._model.path;
const promises = [];
for (const path of this._clipboard) {
if (this._isCut) {
const localPath = this._manager.services.contents.localPath(path);
const parts = localPath.split('/');
const name = parts[parts.length - 1];
const newPath = PathExt.join(basePath, name);
promises.push(this._model.manager.rename(path, newPath));
}
else {
promises.push(this._model.manager.copy(path, basePath));
}
}
// Remove any cut modifiers.
for (const item of this._items) {
item.classList.remove(CUT_CLASS);
}
this._clipboard.length = 0;
this._isCut = false;
this.removeClass(CLIPBOARD_CLASS);
return Promise.all(promises)
.then(() => {
return undefined;
})
.catch(error => {
void showErrorMessage(this._trans._p('showErrorMessage', 'Paste Error'), error);
});
}
/**
* Delete the currently selected item(s).
*
* @returns A promise that resolves when the operation is complete.
*/
async delete() {
const deleteToTrash = PageConfig.getOption('delete_to_trash') === 'true';
const items = this._sortedItems.filter(item => this.selection[item.path]);
if (!items.length) {
return;
}
const moveToTrashActionMessage = this._trans.__('Are you sure you want to move to trash: %1?', items[0].name);
const deleteActionMessage = this._trans.__('Are you sure you want to permanently delete: %1?', items[0].name);
const moveToTrashItemsActionMessage = this._trans._n('Are you sure you want to move to trash the %1 selected item?', 'Are you sure you want to move to trash the %1 selected items?', items.length);
const deleteActionItemsMessage = this._trans._n('Are you sure you want to permanently delete the %1 selected item?', 'Are you sure you want to permanently delete the %1 selected items?', items.length);
const actionMessage = deleteToTrash
? moveToTrashActionMessage
: deleteActionMessage;
const itemsActionMessage = deleteToTrash
? moveToTrashItemsActionMessage
: deleteActionItemsMessage;
const actionName = deleteToTrash
? this._trans.__('Move to Trash')
: this._trans.__('Delete');
const message = items.length === 1 ? actionMessage : itemsActionMessage;
const result = await showDialog({
title: actionName,
body: message,
buttons: [
Dialog.cancelButton({ label: this._trans.__('Cancel') }),
Dialog.warnButton({ label: actionName })
],
// By default focus on "Cancel" to protect from accidental deletion
// ("delete" and "Enter" are next to each other on many keyboards).
defaultButton: 0
});
if (!this.isDisposed && result.button.accept) {
await this._delete(items.map(item => item.path));
}
// Re-focus
let focusIndex = this._focusIndex;
const lastIndexAfterDelete = this._sortedItems.length - items.length - 1;
if (focusIndex > lastIndexAfterDelete) {
// If the focus index after deleting items is out of bounds, set it to the
// last item.
focusIndex = Math.max(0, lastIndexAfterDelete);
}
this._focusItem(focusIndex);
}
/**
* Duplicate the currently selected item(s).
*
* @returns A promise that resolves when the operation is complete.
*/
duplicate() {
const basePath = this._model.path;
const promises = [];
for (const item of this.selectedItems()) {
if (item.type !== 'directory') {
promises.push(this._model.manager.copy(item.path, basePath));
}
}
return Promise.all(promises)
.then(() => {
return undefined;
})
.catch(error => {
void showErrorMessage(this._trans._p('showErrorMessage', 'Duplicate file'), error);
});
}
/**
* Download the currently selected item(s).
*/
async download() {
await Promise.all(Array.from(this.selectedItems())
.filter(item => item.type !== 'directory')
.map(item => this._model.download(item.path)));
}
/**
* Restore the state of the file browser listing.
*
* @param id - The unique ID that is used to construct a state database key.
*
*/
async restore(id) {
const key = `file-browser-${id}:columns`;
const state = this._state;
this._stateColumnsKey = key;
if (!state) {
return;
}
try {
const columns = await state.fetch(key);
if (!columns) {
return;
}
const sizes = columns['sizes'];
if (!sizes) {
return;
}
for (const [key, size] of Object.entries(sizes)) {
this._columnSizes[key] = size;
}
this._updateColumnSizes();
}
catch (error) {
await state.remove(key);
}
}
/**
* Shut down kernels on the applicable currently selected items.
*
* @returns A promise that resolves when the operation is complete.
*/
shutdownKernels() {
const model = this._model;
const items = this._sortedItems;
const paths = items.map(item => item.path);
const promises = Array.from(this._model.sessions())
.filter(session => {
const index = ArrayExt.firstIndexOf(paths, session.path);
return this.selection[items[index].path];
})
.map(session => model.manager.services.sessions.shutdown(session.id));
return Promise.all(promises)
.then(() => {
return undefined;
})
.catch(error => {
void showErrorMessage(this._trans._p('showErrorMessage', 'Shut down kernel'), error);
});
}
/**
* Select next item.
*
* @param keepExisting - Whether to keep the current selection and add to it.
*/
selectNext(keepExisting = false) {
let index = -1;
const selected = Object.keys(this.selection);
const items = this._sortedItems;
if (selected.length === 1 || keepExisting) {
// Select the next item.
const path = selected[selected.length - 1];
index = ArrayExt.findFirstIndex(items, value => value.path === path);
index += 1;
if (index === this._items.length) {
index = 0;
}
}
else if (selected.length === 0) {
// Select the first item.
index = 0;
}
else {
// Select the last selected item.
const path = selected[selected.length - 1];
index = ArrayExt.findFirstIndex(items, value => value.path === path);
}
if (index !== -1) {
this._selectItem(index, keepExisting);
ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
}
}
/**
* Select previous item.
*
* @param keepExisting - Whether to keep the current selection and add to it.
*/
selectPrevious(keepExisting = false) {
let index = -1;
const selected = Object.keys(this.selection);
const items = this._sortedItems;
if (selected.length === 1 || keepExisting) {
// Select the previous item.
const path = selected[0];
index = ArrayExt.findFirstIndex(items, value => value.path === path);
index -= 1;
if (index === -1) {
index = this._items.length - 1;
}
}
else if (selected.length === 0) {
// Select the last item.
index = this._items.length - 1;
}
else {
// Select the first selected item.
const path = selected[0];
index = ArrayExt.findFirstIndex(items, value => value.path === path);
}
if (index !== -1) {
this._selectItem(index, keepExisting);
ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
}
}
/**
* Select the first item that starts with prefix being typed.
*/
selectByPrefix() {
const prefix = this._searchPrefix.toLowerCase();
const items = this._sortedItems;
const index = ArrayExt.findFirstIndex(items, value => {
return value.name.toLowerCase().substr(0, prefix.length) === prefix;
});
if (index !== -1) {
this._selectItem(index, false);
ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
}
}
/**
* Get whether an item is selected by name.
*
* @param name - The name of of the item.
*
* @returns Whether the item is selected.
*/
isSelected(name) {
const items = this._sortedItems;
return (Array.from(filter(items, item => item.name === name && this.selection[item.path])).length !== 0);
}
/**
* Find a model given a click.
*
* @param event - The mouse event.
*
* @returns The model for the selected file.
*/
modelForClick(event) {
const items = this._sortedItems;
const index = Private.hitTestNodes(this._items, event);
if (index !== -1) {
return items[index];
}
return undefined;
}
/**
* Clear the selected items.
*/
clearSelectedItems() {
this.selection = Object.create(null);
}
/**
* Select an item by name.
*
* @param name - The name of the item to select.
* @param focus - Whether to move focus to the selected item.
*
* @returns A promise that resolves when the name is selected.
*/
async selectItemByName(name, focus = false) {
return this._selectItemByName(name, focus);
}
/**
* Select an item by name.
*
* @param name - The name of the item to select.
* @param focus - Whether to move focus to the selected item.
* @param force - Whether to proceed with selection even if the file was already selected.
*
* @returns A promise that resolves when the name is selected.
*/
async _selectItemByName(name, focus = false, force = false) {
if (!force && this.isSelected(name)) {
// Avoid API polling and DOM updates if already selected
return;
}
// Make sure the file is available.
await this.model.refresh();
if (this.isDisposed) {
throw new Error('File browser is disposed.');
}
const items = this._sortedItems;
const index = ArrayExt.findFirstIndex(items, value => value.name === name);
if (index === -1) {
throw new Error('Item does not exist.');
}
this._selectItem(index, false, focus);
MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest);
ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
}
/**
* Handle the DOM events for the directory listing.
*
* @param event - The DOM event sent to the widget.
*
* #### Notes
* This method implements the DOM `EventListener` interface and is
* called in response to events on the panel's DOM node. It should
* not be called directly by user code.
*/
handleEvent(event) {
switch (event.type) {
case 'mousedown':
this._evtMousedown(event);
break;
case 'mouseup':
this._evtMouseup(event);
break;
case 'mousemove':
this._evtMousemove(event);
break;
case 'keydown':
this.evtKeydown(event);
break;
case 'click':
this._evtClick(event);
break;
case 'dblclick':
this.evtDblClick(event);
break;
case 'dragenter':
case 'dragover':
this.addClass('jp-mod-native-drop');
event.preventDefault();
break;
case 'dragleave':
case 'dragend':
this.removeClass('jp-mod-native-drop');
break;
case 'drop':
this.removeClass('jp-mod-native-drop');
this.evtNativeDrop(event);
break;
case 'scroll':
this._evtScroll(event);
break;
case 'lm-dragenter':
this.evtDragEnter(event);
break;
case 'lm-dragleave':
this.evtDragLeave(event);
break;
case 'lm-dragover':
this.evtDragOver(event);
break;
case 'lm-drop':
this.evtDrop(event);
break;
default:
break;
}
}
/**
* A message handler invoked on an `'after-attach'` message.
*/
onAfterAttach(msg) {
super.onAfterAttach(msg);
const node = this.node;
this._width = this._computeContentWidth();
const content = DOMUtils.findElement(node, CONTENT_CLASS);
node.addEventListener('mousedown', this);
node.addEventListener('keydown', this);
node.addEventListener('click', this);
node.addEventListener('dblclick', this);
this._contentSizeObserver.observe(content);
content.addEventListener('dragenter', this);
content.addEventListener('dragover', this);
content.addEventListener('dragleave', this);
content.addEventListener('dragend', this);
content.addEventListener('drop', this);
content.addEventListener('scroll', this);
content.addEventListener('lm-dragenter', this);
content.addEventListener('lm-dragleave', this);
content.addEventListener('lm-dragover', this);
content.addEventListener('lm-drop', this);
this._updateColumnSizes();
}
/**
* A message handler invoked on a `'before-detach'` message.
*/
onBeforeDetach(msg) {
super.onBeforeDetach(msg);
const node = this.node;
const content = DOMUtils.findElement(node, CONTENT_CLASS);
node.removeEventListener('mousedown', this);
node.removeEventListener('keydown', this);
node.removeEventListener('click', this);
node.removeEventListener('dblclick', this);
this._contentSizeObserver.disconnect();
content.removeEventListener('scroll', this);
content.removeEventListener('dragover', this);
content.removeEventListener('dragover', this);
content.removeEventListener('dragleave', this);
content.removeEventListener('dragend', this);
content.removeEventListener('drop', this);
content.removeEventListener('lm-dragenter', this);
content.removeEventListener('lm-dragleave', this);
content.removeEventListener('lm-dragover', this);
content.removeEventListener('lm-drop', this);
document.removeEventListener('mousemove', this, true);
document.removeEventListener('mouseup', this, true);
}
/**
* A message handler invoked on an `'after-show'` message.
*/
onAfterShow(msg) {
if (this._isDirty) {
// Update the sorted items.
this.sort(this.sortState);
this.update();
}
}
_onContentResize() {
const content = DOMUtils.findElement(this.node, CONTENT_CLASS);
const scrollbarWidth = content.offsetWidth - content.clientWidth;
if (scrollbarWidth != this._contentScrollbarWidth) {
this._contentScrollbarWidth = scrollbarWidth;
this._width = this._computeContentWidth();
this._updateColumnSizes();
}
}
_computeContentWidth(width = null) {
if (!width) {
width = this.node.getBoundingClientRect().width;
}
this._paddingWidth = parseFloat(window
.getComputedStyle(this.node)
.getPropertyValue('--jp-dirlisting-padding-width'));
const handle = this.node.querySelector(`.${RESIZE_HANDLE_CLASS}`);
this._handleWidth = handle
? handle.getBoundingClientRect().width
: DEFAULT_HANDLE_WIDTH;
return width - this._paddingWidth * 2 - this._contentScrollbarWidth;
}
/**
* Update the modified column's size
*/
_updateModifiedSize(node) {
var _a, _b;
// Look for the modified column's header
const modified = DOMUtils.findElement(node, MODIFIED_ID_CLASS);
this._modifiedWidth =
(_b = (_a = this._columnSizes['last_modified']) !== null && _a !== void 0 ? _a : modified === null || modified === void 0 ? void 0 : modified.getBoundingClientRect().width) !== null && _b !== void 0 ? _b : 83;
this._modifiedStyle =
this._modifiedWidth < 100
? 'narrow'
: this._modifiedWidth > 120
? 'long'
: 'short';
}
/**
* Rerender item nodes' modified dates, if the modified style has changed.
*/
_updateModifiedStyleAndSize() {
const oldModifiedStyle = this._modifiedStyle;
// Update both size and style
this._updateModifiedSize(this.node);
if (oldModifiedStyle !== this._modifiedStyle) {
this.updateModified(this._sortedItems, this._items);
}
}
/**
* Update only the modified dates.
*/
updateModified(items, nodes) {
items.forEach((item, i) => {
const node = nodes[i];
if (node && item.last_modified) {
const modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS);
if (this.renderer.updateItemModified !== undefined) {
this.renderer.updateItemModified(modified, item.last_modified, this._modifiedStyle);
}
else {
DirListing.defaultRenderer.updateItemModified(modified, item.last_modified, this._modifiedStyle);
}
}
});
}
// Update item nodes based on widget state.
updateNodes(items, nodes, sizeOnly = false) {
var _a;
items.forEach((item, i) => {
const node = nodes[i];
if (sizeOnly && this.renderer.updateItemSize) {
if (!node) {
// short-circuit in case if node is not yet ready
return;
}
return this.renderer.updateItemSize(node, item, this._modifiedStyle, this._columnSizes);
}
const ft = this._manager.registry.getFileTypeForModel(item);
this.renderer.updateItemNode(node, item, ft, this.translator, this._hiddenColumns, this.selection[item.path], this._modifiedStyle, this._columnSizes);
if (this.selection[item.path] &&
this._isCut &&
this._model.path === this._prevPath) {
node.classList.add(CUT_CLASS);
}
// add metadata to the node
node.setAttribute('data-isdir', item.type === 'directory' ? 'true' : 'false');
});
// Handle the selectors on the widget node.
const selected = Object.keys(this.selection).length;
if (selected) {
this.addClass(SELECTED_CLASS);
if (selected > 1) {
this.addClass(MULTI_SELECTED_CLASS);
}
}
// Handle file session statuses.
const paths = items.map(item => item.path);
for (const session of this._model.sessions()) {
const index = ArrayExt.firstIndexOf(paths, session.path);
const node = nodes[index];
// Node may have been filtered out.
if (node) {
let name = (_a = session.kernel) === null || _a === void 0 ? void 0 : _a.name;
const specs = this._model.specs;
node.classList.add(RUNNING_CLASS);
if (specs && name) {
const spec = specs.kernelspecs[name];
name = spec ? spec.display_name : this._trans.__('unknown');
}
// Update node title only if it has changed.
const prevState = this._lastRenderedState.get(node);
if (prevState !== node.title) {
node.title = this._trans.__('%1\nKernel: %2', node.title, name);
this._lastRenderedState.set(node, node.title);
}
}
}
}
/**
* A handler invoked on an `'update-request'` message.
*/
onUpdateRequest(msg) {
this._isDirty = false;
// Fetch common variables.
const items = this._sortedItems;
const nodes = this._items;
// If we didn't get any item yet, we'll need to resize once items are added
const needsResize = nodes.length === 0;
const content = DOMUtils.findElement(this.node, CONTENT_CLASS);
const renderer = this._renderer;
this.removeClass(MULTI_SELECTED_CLASS);
this.removeClass(SELECTED_CLASS);
// Remove any excess item nodes.
while (nodes.length > items.length) {
content.removeChild(nodes.pop());
}
// Add any missing item nodes.
while (nodes.length < items.length) {
const node = renderer.createItemNode(this._hiddenColumns, this._columnSizes);
node.classList.add(ITEM_CLASS);
nodes.push(node);
content.appendChild(node);
}
nodes.forEach((node, i) => {
// Remove extra classes from the nodes.
node.classList.remove(SELECTED_CLASS);
node.classList.remove(RUNNING_CLASS);
node.classList.remove(CUT_CLASS);
// Uncheck each file checkbox
const checkbox = renderer.getCheckboxNode(node);
if (checkbox) {
checkbox.checked = false;
}
// Handle `tabIndex` & `role`
const nameNode = renderer.getNameNode(node);
if (nameNode) {
// Must check if the name node is there because it gets replaced by the
// edit node when editing the name of the file or directory.
if (i === this._focusIndex) {
nameNode.setAttribute('tabIndex', '0');
nameNode.setAttribute('role', 'button');
}
else {
nameNode.setAttribute('tabIndex', '-1');
nameNode.removeAttribute('role');
}
}
});
// Put the check-all checkbox in the header into the correct state
const checkAllCheckbox = renderer.getCheckboxNode(this.headerNode);
if (checkAllCheckbox) {
const totalSelected = Object.keys(this.selection).length;
const allSelected = items.length > 0 && totalSelected === items.length;
const someSelected = !allSelected && totalSelected > 0;
checkAllCheckbox.checked = allSelected;
checkAllCheckbox.indeterminate = someSelected;
// Stash the state in data attributes so we can access them in the click
// handler (because in the click handler, checkbox.checked and
// checkbox.indeterminate do not hold the previous value; they hold the
// next value).
checkAllCheckbox.dataset.checked = String(allSelected);
checkAllCheckbox.dataset.indeterminate = String(someSelected);
const trans = this.translator.load('jupyterlab');
checkAllCheckbox === null || checkAllCheckbox === void 0 ? void 0 : checkAllCheckbox.setAttribute('aria-label', allSelected || someSelected
? trans.__('Deselect all files and directories')
: trans.__('Select all files and directories'));
}
this.updateNodes(items, nodes);
if (needsResize) {
this._width = this._computeContentWidth();
this._updateColumnSizes();
}
this._prevPath = this._model.path;
}
onResize(msg) {
const { width } = msg.width === -1 ? this.node.getBoundingClientRect() : msg;
this._width = this._computeContentWidth(width);
this._updateColumnSizes();
}
setColumnVisibility(name, visible) {
if (visible) {
this._hiddenColumns.delete(name);
}
else {
this._hiddenColumns.add(name);
}
this.headerNode.innerHTML = '';
this._renderer.populateHeaderNode(this.headerNode, this.translator, this._hiddenColumns, this._columnSizes);
this._updateColumnSizes();
}
_updateColumnSizes(doNotGrowBeforeInclusive = null) {
// Adjust column sizes so that they add up to the total width available, preserving ratios
const visibleColumns = this._visibleColumns
.map(column => ({
...column,
element: DOMUtils.findElement(this.node, column.className)
}))
.filter(column => {
// While all visible column will have an element, some extensions like jupyter-unfold
// do not render columns even if user requests them to be visible; this filter exists
// to ensure backward compatibility with such extensions and may be removed in the future.
return column.element;
});
// read from DOM
let total = 0;
for (const column of visibleColumns) {
let size = this._columnSizes[column.id];
if (size === null) {
size = column.element.getBoundingClientRect().width;
}
// restrict the minimum and maximum width
size = Math.max(size, column.minWidth);
if (this._width) {
let reservedForOtherColumns = 0;
for (const other of visibleColumns) {
if (other.id === column.id) {
continue;
}
reservedForOtherColumns += other.minWidth;
}
size = Math.min(size, this._width - reservedForOtherColumns);
}
this._columnSizes[column.id] = size;
total += size;
}
// Ensure that total fits
if (this._width) {
// Distribute the excess/shortfall over the columns which should stretch.
const excess = this._width - total;
let growAllowed = doNotGrowBeforeInclusive === null;
const growColumns = visibleColumns.filter(c => {
if (growAllowed) {
return true;
}
if (c.id === doNotGrowBeforeInclusive) {
growAllowed = true;
}
return false;
});
const totalWeight = growColumns
.map(c => c.grow)
.reduce((a, b) => a + b, 0);
for (const column of growColumns) {
// The value of `growBy` will be negative when the down-sizing
const growBy = (excess * column.grow) / totalWeight;
this._columnSizes[column.id] = this._columnSizes[column.id] + growBy;
}
}
const resizeHandles = this.node.getElementsByClassName(RESIZE_HANDLE_CLASS);
const resizableColumns = visibleColumns.map(column => Private.isResizable(column));
// Write to DOM
let i = 0;
for (const column of visibleColumns) {
let size = this._columnSizes[column.id];
if (Private.isResizable(column) && size) {
size -=
(this._handleWidth * resizeHandles.length) / resizableColumns.length;
// if this is first resizable or last resizable column
if (i === 0 || i === resizableColumns.length - 1) {
size += this._paddingWidth;
}
i += 1;
}
column.element.style.width = size === null ? '' : size + 'px';
}
this._updateModifiedStyleAndSize();
// Refresh sizes on the per item widths
if (this.isVisible) {
const items = this._items;
if (items.length !== 0) {
this.updateNodes(this._sortedItems, this._items, true);
}
}
if (this._state && this._stateColumnsKey) {
void this._state.save(this._stateColumnsKey, {
sizes: this._columnSizes
});
}
}
get _visibleColumns() {
return DirListing.columns.filter(column => { var _a; return column.id === 'name' || !((_a = this._hiddenColumns) === null || _a === void 0 ? void 0 : _a.has(column.id)); });
}
_setColumnSize(name, size) {
var _a;
const previousSize = this._columnSizes[name];
if (previousSize && size && size > previousSize) {
// check if we can resize up
let total = 0;
let before = true;
for (const column of this._visibleColumns) {
if (column.id === name) {
// add proposed size for the current columns
total += size;
before = false;
continue;
}
if (before) {
// add size as-is for columns before
const element = DOMUtils.findElement(this.node, column.className);
total +=
(_a = this._columnSizes[column.id]) !== null && _a !== void 0 ? _a : element.getBoundingClientRect().width;
}
else {
// add minimum acceptable size for columns after
total += column.minWidth;
}
}
if (this._width && total > this._width) {
// up sizing is no longer possible
return;
}
}
this._columnSizes[name] = size;
this._updateColumnSizes(name);
}
/**
* Update the setting to sort notebooks above files.
* This sorts the items again if the internal value is modified.
*/
setNotebooksFirstSorting(isEnabled) {
let previousValue = this._sortNotebooksFirst;
this._sortNotebooksFirst = isEnabled;
if (this._sortNotebooksFirst !== previousValue) {
this.sort(this._sortState);
}
}
/**
* Update the setting to allow single click navigation.
* This enables opening files/directories with a single click.
*/
setAllowSingleClickNavigation(isEnabled) {
this._allowSingleClick = isEnabled;
}
/**
* Would this click (or other event type) hit the checkbox by default?
*/
isWithinCheckboxHitArea(event) {
let element = event.target;
while (element) {
if (element.classList.contains(CHECKBOX_WRAPPER_CLASS)) {
return true;
}
element = element.parentElement;
}
return false;
}
/**
* Handle the `'click'` event for the widget.
*/
_evtClick(event) {
const target = event.target;
const header = this.headerNode;
const renderer = this._renderer;
if (header.contains(target)) {
const checkbox = renderer.getCheckboxNode(header);
if (checkbox && this.isWithinCheckboxHitArea(event)) {
const previouslyUnchecked = checkbox.dataset.indeterminate === 'false' &&
checkbox.dataset.checked === 'false';
// The only time a click on the check-all checkbox should check all is
// when it was previously unchecked; otherwise, if the checkbox was
// either checked (all selected) or indeterminate (some selected), the
// click should clear all.
if (previouslyUnchecked) {
// Select all items
this._sortedItems.forEach((item) => (this.selection[item.path] = true));
}
else {
// Unselect all items
this.clearSelectedItems();
}
this.update();
}
else {
const state = this.renderer.handleHeaderClick(header, event);
if (state) {
this.sort(state);
}
}
return;
}
else {
// Focus the selected file on click to ensure a couple of things:
// 1. If a user clicks on the item node, its name node will receive focus.
// 2. If a user clicks on blank space in the directory listing, the
// previously focussed item will be focussed.
this._focusItem(this._focusIndex);
}
if (this._allowSingleClick) {
this.evtDblClick(event);
}
}
/**
* Handle the `'scroll'` event for the widget.
*/
_evtScroll(event) {
this.headerNode.scrollLeft = this.contentNode.scrollLeft;
}
/**
* Handle the `'mousedown'` event for the widget.
*/
_evtMousedown(event) {
// Bail if clicking within the edit node
if (event.target === this._editNode) {
return;
}
// Blur the edit node if necessary.
if (this._editNode.parentNode) {
if (this._editNode !== event.target) {
this._editNode.focus();
this._editNode.blur();
clearTimeout(this._selectTimer);
}
else {
return;
}
}
let index = Private.hitTestNodes(this._items, event);
if (index === -1) {
// Left mouse press for drag or resize start.
if (event.button === 0) {
const resizeHandle = event.target;
if (resizeHandle instanceof HTMLElement &&
resizeHandle.classList.contains(RESIZE_HANDLE_CLASS)) {
const columnId = resizeHandle.dataset.column;
if (!columnId) {
throw Error('Column resize handle is missing data-column attribute');
}
const column = DirListing.columns.find(c => c.id === columnId);
if (!column) {
throw Error(`Column with identifier ${columnId} not found`);
}
const element = DOMUtils.findElement(this.node, column.className);
resizeHandle.classList.add(ACTIVE_CLASS);
const cursorOverride = Drag.overrideCursor('col-resize');
this._resizeData = {
pressX: event.clientX,
column: columnId,
initialSize: element.getBoundingClientRect().width,
overrides: new DisposableDelegate(() => {
cursorOverride.dispose();
resizeHandle.classList.remove(ACTIVE_CLASS);
})
};
document.addEventListener('mouseup', this, true);
document.addEventListener('mousemove', this, true);
return;
}
}
return;
}
this.handleFileSelect(event);
if (event.button !== 0) {
clearTimeout(this._selectTimer);
}
// Check for clearing a context menu.
const newContext = (IS_MAC && event.ctrlKey) || event.button === 2;
if (newContext) {
return;
}
// Left mouse press for drag or resize start.
if (event.button === 0) {
this._dragData = {
pressX: event.clientX,
pressY: event.clientY,
index: index
};
document.addEventListener('mouseup', this, true);
document.addEventListener('mousemove', this, true);
}
}
/**
* Handle the `'mouseup'` event for the widget.
*/
_evtMouseup(event) {
// Handle any soft selection from the previous mouse down.
if (this._softSelection) {
const altered = event.metaKey || event.shiftKey || event.ctrlKey;
// See if we need to clear the other selection.
if (!altered && event.button === 0) {
this.clearSelectedItems();
this.selection[this._softSelection] = true;
this.update();
}
this._softSelection = '';
}
// Re-focus. This is needed because nodes corresponding to files selected in
// mousedown handler will not retain the focus as mousedown event is always
// followed by a blur/focus event.
if (event.button === 0) {
this._focusItem(this._focusIndex);
}
// Remove the resize listeners if necessary.
if (this._resizeData) {
this._resizeData.overrides.dispose();
this._resizeData = null;
document.removeEventListener('mousemove', this, true);
document.removeEventListener('mouseup', this, true);
return;
}
// Remove the drag listeners if necessary.
if (event.button !== 0 || !this._drag) {
document.removeEventListener('mousemove', this, true);
document.removeEventListener('mouseup', this, true);
return;
}
event.preventDefault();
event.stopPropagation();
}
/**
* Handle the `'mousemove'` event for the widget.
*/
_evtMousemove(event) {
event.preventDefault();
event.stopPropagation();
if (this._resizeData) {
const { initialSize, column, pressX } = this._resizeData;
this._setColumnSize(column, initialSize + event.clientX - pressX);
return;
}
// Bail if we are the one dragging.
if (this._drag || !this._dragData) {
return;
}
// Check for a drag initialization.
const data = this._dragData;
const dx = Math.abs(event.clientX - data.pressX);
const dy = Math.abs(event.clientY - data.pressY);
if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) {
return;
}
this._startDrag(data.index, event.clientX, event.clientY);
}
/**
* Handle the opening of an item.
*/
handleOpen(item) {
this._onItemOpened.emit(item);
if (item.type === 'directory') {
const localPath = this._manager.services.contents.localPath(item.path);
this._model