@jupyterlab/filebrowser
Version:
JupyterLab - FileBrowser Widget
4,260 lines • 126 kB
text/typescript
// 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 type { IDocumentManager } from '@jupyterlab/docmanager';
import { isValidFileName, renameFile } from '@jupyterlab/docmanager';
import { DocumentRegistry } from '@jupyterlab/docregistry';
import type { Contents } from '@jupyterlab/services';
import type { IStateDB } from '@jupyterlab/statedb';
import type { ITranslator, TranslationBundle } from '@jupyterlab/translation';
import { nullTranslator } from '@jupyterlab/translation';
import {
caretDownIcon,
caretUpIcon,
classes,
LabIcon
} from '@jupyterlab/ui-components';
import { ArrayExt, filter, StringExt } from '@lumino/algorithm';
import type { ReadonlyJSONObject } from '@lumino/coreutils';
import { MimeData, PromiseDelegate } from '@lumino/coreutils';
import { ElementExt } from '@lumino/domutils';
import type { IDisposable } from '@lumino/disposable';
import { DisposableDelegate } from '@lumino/disposable';
import { Drag } from '@lumino/dragdrop';
import type { Message } from '@lumino/messaging';
import { MessageLoop } from '@lumino/messaging';
import type { ISignal } from '@lumino/signaling';
import { Signal } from '@lumino/signaling';
import { h, VirtualDOM } from '@lumino/virtualdom';
import { Widget } from '@lumino/widgets';
import type { FilterFileBrowserModel } from './model';
/**
* 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 listing item created cell.
*/
const ITEM_CREATED_CLASS = 'jp-DirListing-itemCreated';
/**
* 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 class name added to the created column header cell.
*/
const CREATED_ID_CLASS = 'jp-id-created';
/**
* 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: DirListing.IOptions) {
super({
node: (options.renderer || DirListing.defaultRenderer).createNode()
});
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;
this._allowDragDropUpload = options.allowDragDropUpload ?? true;
this._handleOpenFile =
options.handleOpenFile ||
((path: string) => {
this._manager.openOrReveal(path);
});
// Get the width of the "modified" column
this._updateModifiedSize(this.node);
// Get the width of the "created" column
this._updateCreatedSize(this.node);
const headerNode = DOMUtils.findElement(this.node, HEADER_CLASS);
// hide the file size column by default
this._hiddenColumns.add('file_size');
// hide the date created column by default
this._hiddenColumns.add('date_created');
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(): void {
this._items.length = 0;
this._sortedItems.length = 0;
this._clipboard.length = 0;
super.dispose();
}
/**
* Get the model used by the listing.
*/
get model(): FilterFileBrowserModel {
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(): HTMLElement {
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(): HTMLElement {
return DOMUtils.findElement(this.node, CONTENT_CLASS);
}
/**
* The renderer instance used by the directory listing.
*/
get renderer(): DirListing.IRenderer {
return this._renderer;
}
/**
* The current sort state.
*/
get sortState(): DirListing.ISortState {
return this._sortState;
}
/**
* A signal fired when an item is opened.
*/
get onItemOpened(): ISignal<DirListing, Contents.IModel> {
return this._onItemOpened;
}
/**
* A signal fired when the selection changes.
*/
get selectionChanged(): ISignal<DirListing, void> {
return this._selectionChanged;
}
/**
* Create an iterator over the listing's selected items.
*
* @returns A new iterator over the listing's selected items.
*/
selectedItems(): IterableIterator<Contents.IModel> {
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(): IterableIterator<Contents.IModel> {
return this._sortedItems[Symbol.iterator]();
}
/**
* Sort the items using a sort condition.
*/
sort(state: DirListing.ISortState): void {
this._sortedItems = Private.sort(
this.model.items(),
state,
this._sortNotebooksFirst,
this._sortFileNamesNaturally,
this.translator
);
this._sortState = state;
this.update();
this._saveState();
}
/**
* Rename the first currently selected item.
*
* @returns A promise that resolves with the new name of the item.
*/
rename(): Promise<string> {
return this._doRename();
}
/**
* Cut the selected items.
*/
cut(): void {
this._isCut = true;
this._copy();
this.update();
}
/**
* Copy the selected items.
*/
copy(): void {
this._copy();
}
/**
* Paste the items from the clipboard.
*
* @returns A promise that resolves when the operation is complete.
*/
paste(): Promise<void> {
if (!this._clipboard.length) {
this._isCut = false;
return Promise.resolve(undefined);
}
const basePath = this._model.path;
const promises: Promise<Contents.IModel>[] = [];
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(): Promise<void> {
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(): Promise<void> {
const basePath = this._model.path;
const promises: Promise<Contents.IModel>[] = [];
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
);
});
}
/**
* Select all listing items in the current directory.
*/
async selectAll(): Promise<void> {
const items = this._model.items();
const newSelection: { [key: string]: boolean } = {};
for (const item of items) {
newSelection[item.path] = true;
}
this.selection = newSelection;
this._selectionChanged.emit();
this.update();
}
/**
* Download the currently selected item(s).
*/
async download(): Promise<void> {
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: string): Promise<void> {
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 as ReadonlyJSONObject)['sizes'] as
| Record<DirListing.IColumn['id'], number | null>
| undefined;
const sortState = (columns as ReadonlyJSONObject)[
'sortState'
] as unknown as DirListing.ISortState | undefined;
// Set sort state before updating column sizes so that any
// state save triggered by _updateColumnSizes includes the
// correct sort state rather than the default.
if (sortState) {
this._sortState = sortState;
}
if (sizes) {
for (const [key, size] of Object.entries(sizes)) {
this._columnSizes[key as DirListing.IColumn['id']] = size;
}
this._updateColumnSizes();
}
// Sort items and rebuild the header to reflect the restored
// sort state without calling sort() to avoid a redundant save.
if (sortState) {
this._sortedItems = Private.sort(
this.model.items(),
sortState,
this._sortNotebooksFirst,
this._sortFileNamesNaturally,
this.translator
);
this.headerNode.innerHTML = '';
this._renderer.populateHeaderNode(
this.headerNode,
this.translator,
this._hiddenColumns,
this._columnSizes,
sortState
);
this.update();
}
} catch (error) {
await state.remove(key);
}
}
private _stateColumnsKey: string;
/**
* Save the current column sizes and sort state to the state database.
*/
private _saveState(): void {
if (this._state && this._stateColumnsKey) {
void this._state.save(this._stateColumnsKey, {
sizes: this._columnSizes,
sortState: { ...this._sortState }
});
}
}
/**
* Shut down kernels on the applicable currently selected items.
*
* @returns A promise that resolves when the operation is complete.
*/
shutdownKernels(): Promise<void> {
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): void {
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): void {
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(): void {
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: string): boolean {
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: MouseEvent): Contents.IModel | undefined {
const items = this._sortedItems;
const index = Private.hitTestNodes(this._items, event);
if (index !== -1) {
return items[index];
}
return undefined;
}
/**
* Clear the selected items.
*/
clearSelectedItems(emit = true): void {
this.selection = Object.create(null);
if (emit) {
this._selectionChanged.emit();
}
}
/**
* Move focus to selected item, or to the first item if no item is selected.
*/
private _focusContent(): void {
const items = this._sortedItems;
if (items.length === 0) {
this._focusItem(0);
return;
}
let index =
this._focusPath !== ''
? ArrayExt.findFirstIndex(
items,
value => value.path === this._focusPath
)
: -1;
// Fallbacks if path is missing/not found.
if (index === -1) {
index = Math.min(Math.max(this._focusIndex, 0), items.length - 1);
}
this._selectItem(index, false, true);
}
/**
* Handle `'activate-request'` messages.
*/
protected onActivateRequest(msg: Message): void {
this._focusContent();
}
/**
* 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: string, focus: boolean = false): Promise<void> {
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.
*/
private async _selectItemByName(
name: string,
focus: boolean = false,
force: boolean = false
): Promise<void> {
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: Event): void {
switch (event.type) {
case 'mousedown':
this._evtMousedown(event as MouseEvent);
break;
case 'mouseup':
this._evtMouseup(event as MouseEvent);
break;
case 'mousemove':
this._evtMousemove(event as MouseEvent);
break;
case 'keydown':
this.evtKeydown(event as KeyboardEvent);
break;
case 'click':
this._evtClick(event as MouseEvent);
break;
case 'dblclick':
this.evtDblClick(event as MouseEvent);
break;
case 'dragenter':
case 'dragover':
// Only show visual feedback if drag and drop upload is enabled
if (this._allowDragDropUpload) {
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 as DragEvent);
break;
case 'scroll':
this._evtScroll(event as MouseEvent);
break;
case 'lm-dragenter':
this.evtDragEnter(event as Drag.Event);
break;
case 'lm-dragleave':
this.evtDragLeave(event as Drag.Event);
break;
case 'lm-dragover':
this.evtDragOver(event as Drag.Event);
break;
case 'lm-drop':
this.evtDrop(event as Drag.Event);
break;
default:
break;
}
}
/**
* A message handler invoked on an `'after-attach'` message.
*/
protected onAfterAttach(msg: Message): void {
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.
*/
protected onBeforeDetach(msg: Message): void {
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.
*/
protected onAfterShow(msg: Message): void {
if (this._isDirty) {
// Update the sorted items.
this.sort(this.sortState);
this.update();
}
}
private _onContentResize(): void {
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();
}
}
private _computeContentWidth(width: number | null = 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
*/
private _updateModifiedSize(node: HTMLElement) {
this._modifiedStyle = this._computeDateColumnStyle(
node,
MODIFIED_ID_CLASS,
'last_modified'
);
}
/**
* Update the 'date created' column's size
*/
private _updateCreatedSize(node: HTMLElement) {
this._createdStyle = this._computeDateColumnStyle(
node,
CREATED_ID_CLASS,
'date_created'
);
}
/**
* Compute the Intl.RelativeTimeFormat style for a date column based on
* its pixel width: < 100px → 'narrow', 100–120px → 'short', > 120px → 'long'.
*/
private _computeDateColumnStyle(
node: HTMLElement,
columnClassName: string,
columnName: 'last_modified' | 'date_created'
): Time.HumanStyle {
const columnElement = DOMUtils.findElement(node, columnClassName);
const width =
this._columnSizes[columnName] ??
columnElement?.getBoundingClientRect().width ??
83;
return width < 100 ? 'narrow' : width > 120 ? 'long' : 'short';
}
/**
* Rerender item nodes' modified dates, if the modified style has changed.
*/
private _updateModifiedStyleAndSize() {
const oldModifiedStyle = this._modifiedStyle;
this._updateModifiedSize(this.node);
if (oldModifiedStyle !== this._modifiedStyle) {
this.updateModified(this._sortedItems, this._items);
}
}
/**
* Rerender item nodes' created dates, if the created style has changed.
*/
private _updateCreatedStyleAndSize() {
const oldCreatedStyle = this._createdStyle;
this._updateCreatedSize(this.node);
if (oldCreatedStyle !== this._createdStyle) {
this.updateCreated(this._sortedItems, this._items);
}
}
/**
* Update only the modified dates.
*/
protected updateModified(items: Contents.IModel[], nodes: HTMLElement[]) {
items.forEach((item, i) => {
const node = nodes[i];
if (node && item.last_modified) {
const modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS);
if (!modified) {
return;
}
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 only the created dates.
*/
protected updateCreated(items: Contents.IModel[], nodes: HTMLElement[]) {
items.forEach((item, i) => {
const node = nodes[i];
if (node && item.created) {
const created = DOMUtils.findElement(node, ITEM_CREATED_CLASS);
if (!created) {
return;
}
if (this.renderer.updateItemCreated !== undefined) {
this.renderer.updateItemCreated(
created,
item.created,
this._createdStyle
);
} else {
DirListing.defaultRenderer.updateItemCreated(
created,
item.created,
this._createdStyle
);
}
}
});
}
// Update item nodes based on widget state.
protected updateNodes(
items: Contents.IModel[],
nodes: HTMLElement[],
sizeOnly = false
) {
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,
this._createdStyle
);
}
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,
this._createdStyle
);
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 = session.kernel?.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.
*/
protected onUpdateRequest(msg: Message): void {
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?.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: Widget.ResizeMessage): void {
const { width } =
msg.width === -1 ? this.node.getBoundingClientRect() : msg;
this._width = this._computeContentWidth(width);
this._updateColumnSizes();
}
setColumnVisibility(
name: DirListing.ToggleableColumn,
visible: boolean
): void {
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._sortState
);
this._updateColumnSizes();
}
private _updateColumnSizes(
doNotGrowBeforeInclusive: DirListing.IColumn['id'] | null = 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();
this._updateCreatedStyleAndSize();
// 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);
}
}
this._saveState();
}
private get _visibleColumns() {
return DirListing.columns.filter(
column => column.id === 'name' || !this._hiddenColumns?.has(column.id)
);
}
private _setColumnSize(
name: DirListing.ResizableColumn,
size: number | null
): void {
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 +=
this._columnSizes[column.id] ??
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: boolean) {
let previousValue = this._sortNotebooksFirst;
this._sortNotebooksFirst = isEnabled;
if (this._sortNotebooksFirst !== previousValue) {
this.sort(this._sortState);
}
}
/**
* Update the setting to sort file names naturally
* vs lexicographically. Default is true (natural).
* This sorts the items again if the internal value is modified.
*/
setSortFileNamesNaturally(natural: boolean): void {
const previousValue = this._sortFileNamesNaturally;
this._sortFileNamesNaturally = natural;
if (this._sortFileNamesNaturally !== previousValue) {
this.sort(this._sortState);
}
}
/**
* Update the setting to allow single click navigation.
* This enables opening files/directories with a single click.
*/
setAllowSingleClickNavigation(isEnabled: boolean) {
this._allowSingleClick = isEnabled;
}
/**
* Update the setting to allow drag and drop upload.
* This enables uploading files via drag and drop.
*/
setAllowDragDropUpload(isEnabled: boolean) {
this._allowDragDropUpload = isEnabled;
}
/**
* Would this click (or other event type) hit the checkbox by default?
*/
protected isWithinCheckboxHitArea(event: Event): boolean {
let element: HTMLElement | null = event.target as HTMLElement;
while (element) {
if (element.classList.contains(CHECKBOX_WRAPPER_CLASS)) {
return true;
}
element = element.parentElement;
}
return false;
}
/**
* Handle the `'click'` event for the widget.
*/
private _evtClick(event: MouseEvent) {
const target = event.target as HTMLElement;
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: Contents.IModel) => (this.selection[item.path] = true)
);
this._selectionChanged.emit();
} 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 as MouseEvent);
}
}
/**
* Handle the `'scroll'` event for the widget.
*/
private _evtScroll(event: MouseEvent): void {
this.headerNode.scrollLeft = this.contentNode.scrollLeft;
}
/**
* Handle the `'mousedown'` event for the widget.
*/
private _evtMousedown(event: MouseEvent): void {
// 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 as HTMLElement)) {
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 as
| DirListing.ResizableColumn
| undefined;
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.
*/
private _evtMouseup(event: MouseEvent): void {
// 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._selectionChanged.emit();
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.
*/
private _evtMousemove(event: MouseEvent): void {
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.
*/
protected handleOpen(item: Contents.IModel): void {
this._onItemOpened.emit(item);
if (item.type === 'directory') {
const localPath = this._manager.services.contents.localPath(item.path);
this._model
.cd(`/${localPath}`)
.catch(error =>
showErrorMessage(
this._trans._p('showErrorMessage', 'Open directory'),
error
)
);
} else {
const path = item.path;
this._handleOpenFile(path);
}
}
/**
* Calculate the next focus index, given the current focus index and a
* direction, keeping within the bounds of the directory listing.
*
* @param index Current focus index
* @param direction -1 (up) or 1 (down)
* @returns The next focus index, which could be the same as the current focus
* index if at the boundary.
*/
private _getNextFocusIndex(index: number, direction: number): number {
const nextIndex = index + direction;
if (nextIndex === -1 || nextIndex === this._items.length) {
// keep focus index within bounds
return index;
} else {
return nextIndex;
}
}
/**
* Handle the up or down arrow key.
*
* @param event The keyboard event
* @param direction -1 (up) or 1 (down)
*/
private _handleArrowY(event: KeyboardEvent, direction: number) {
// We only handle the `ctrl` and `shift` modifiers. If other modifiers are
// present, then do nothing.
if (event.altKey || event.metaKey) {
return;
}
// If folder is empty, there's nothing to do with the up/down key.
if (!this._items.length) {
return;
}
// Don't handle the arrow key press if it's not on directory item. This
// avoids a confusing user experience that can result from when the user
// moves the selection and focus index apart (via ctrl + up/down). The last
// selected item remains highlighted but the last focussed item loses its
// focus ring if it's not actively focussed. This forces the user to
// visibly reveal the last focussed item before moving the focus.
if (!(event.target as HTMLElement).classList.contains(ITEM_TEXT_CLASS)) {
return;
}
event.stopPropagation();
event.preventDefault();
const focusIndex = this._focusIndex;
let nextFocusIndex = this._getNextFocusIndex(focusIndex, direction);
// The following if-block allows the first press of the down arrow to select
// the first (rather than the second) file/directory in the list. This is
// the situation when the page first loads or when a user changes directory.
if (
direction > 0 &&
focusIndex === 0 &&
!event.ctrlKey &&
Object.keys(this.selection).length === 0
) {
nextFocusIndex = 0;
}
// Shift key indicates multi-selection. Either the user is trying to grow
// the selection, or shrink it.
if (event.shiftKey) {
this._handleMultiSelect(nextFocusIndex);
} else if (!event.ctrlKey) {
// If neither the shift nor ctrl keys were used with the up/down arrow,
// then we treat it as a normal, unmodified key press and select the
// next item.
this._selectItem(
nextFocusIndex,
event.shiftKey,
false /* focus = false because we call focus method directly following this */
);
}
this._focusItem(nextFocusIndex);
this.update();
}
/**
* cd ..
*
* Go up one level in the directory tree.
*/
async goUp() {
const model = this.model;
if (model.path === model.rootPath) {
return;
}
// Check if we're at the navigation root boundary
if (model.root && model.path === model.root) {
return;
}
try {
await model.cd('..');
} catch (reason) {
console.warn(`Failed to go to parent directory of ${model.path}`, reason);
}
}
/**
* Handle the `'keydown'` event for the widget.
*/
protected evtKeydown(event: KeyboardEvent): void {
// Do not handle any keydown events here if in the middle of a file rename.
if (this._inRename) {
return;
}
switch (event.key) {
case 'Enter': {
// Do nothing if any modifier keys are pressed.
if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
return;
}
event.preventDefault();
event.stopPropagation();
for (const item of this.selectedItems()) {
this.handleOpen(item);
}
return;
}
case 'ArrowUp':
this._handleArrowY(event, -1);
return;
case 'ArrowDown':
this._handleArrowY(event, 1);
return;
case ' ':
case 'Spacebar': {
if (event.ctrlKey) {
// Follow the Windows and Ubuntu convention: you must press `ctrl` +
// `space` in order to toggle whether an item is selected.
// However, do not handle if any other modifiers were pressed.
if (event.metaKey || event.shiftKey || event.altKey) {
return;
}
// Make sure the ctrl+space key stroke was on a valid, focussed target.
const node = this._items[this._focusIndex];
if (
!(
// Event must have occurred within a node whose item can be toggled.
(
node.contains(event.target as HTMLElement) &&
// That node must also contain the currently focussed element.
node.contains(document.activeElement)
)
)
) {
return;
}
event.stopPropagation();
// Prevent default, otherwise the container will scroll.
event.preventDefault();
// Toggle item selected
const { path } = this._sortedItems[this._focusIndex];
if (this.selection[path]) {
delete this.selection[path];
} else {
this.selection[path] = true;
}
this._selectionChanged.emit();
this.update();
// Key was handled, so return.
return;
}
break;
}
}
// Detects printable characters typed by the user.
// Not all browsers support .key, but it discharges us from reconstructing
// characters from key codes.
if (
event.key !== undefined &&
event.key.length === 1 &&
// Don't gobble up the space key on the check-all checkbox (which the
// browser treats as a click event).
!(
(event.key === ' ' || event.key === 'Spacebar') &&
(event.target as HTMLInputElement).type === 'checkbox'
)
) {
if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
return;
}
this._searchPrefix += event.key;
clearTimeout(this._searchPrefixTimer);
this._searchPrefixTimer = window.setTimeout(() => {
this._searchPrefix = '';
}, PREFIX_APPEND_DURATION);
this.selectByPrefix();
event.stopPropagation();
event.preventDefault();
}
}
/**
* Handle the `'dblclick'` event for the widget.
*/
protected evtDblClick(event: MouseEvent): void {
// Do nothing if it's not a left mouse press.
if (event.button !== 0) {
return;
}
// Do nothing if any modifier keys are pressed.
if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
return;
}
// Do nothing if the double click is on a checkbox. (Otherwise a rapid
// check-uncheck on the checkbox will cause the adjacent file/folder to
// open, which is probably not what the user intended.)
if (this.isWithinCheckboxHitArea(event)) {
return;
}
// Stop the event propagation.
event.preventDefault();
event.stopPropagation();
clearTimeout(this._selectTimer);
this._editNode.blur();
// Find a valid double click target.
const target = event.target as HTMLElement;
const i = ArrayExt.findFirstIndex(this._items, node =>
node.contains(target)
);
if (i === -1) {
return;
}
const item = this._sortedItems[i];
this.handleOpen(item);
}
/**
* Handle the `drop` event for the widget.
*/
protected evtNativeDrop(event: DragEvent): void {
// Prevent navigation
event.preventDefault();
// Check if drag and drop upload is disabled
if (!this._allowDragDropUpload) {
return;
}
const items = event.dataTransfer?.items;
if (!items) {
// Fallback to simple upload of files (if any)
const files = event.dataTransfer?.files;
if (!files || files.length === 0) {
return;
}
const promises = [];
for (const file of files) {
const promise = this._model.upload(file);
promises.push(promise);
}
Promise.all(promises)
.then(() => this._allUploaded.emit())
.catch(err => {
console.error('Error while uploading files: ', err);
});
return;
}
const uploadEntry = async (entry: FileSystemEntry, path: string) => {
if (Private.isDirectoryEntry(entry)) {
const dirPath = await Private.createDirectory(
this._model.manager,
path,
entry.name
);
const directoryReader = entry.createReader();
const allEntries = await Private.collectEntries(directoryReader);
for (const childEntry of allEntries) {
await uploadEntry(childEntry, dirPath);
}
} else if (Private.isFileEntry(entry)) {
const file = await Private.readFile(entry);
await this._model.upload(file, path);
}
};
const promises = [];
for (const item of items) {
const entry = Private.defensiveGetAsEntry(item);
if (!entry) {
continue;
}
const promise = uploadEntry(entry, this._model.path ?? '/');
promises.push(promise);
}
Promise.all(promises)
.then(() => this._allUploaded.emit())
.catch(err => {
console.error('Error while uploading files: ', err);
});
}
/**
* Signal emitted on when all files were uploaded after native drag.
*/
protected get allUploaded(): ISignal<DirListing, void> {
return this._allUploaded;
}
/**
* Handle the `'lm-dragenter'` event for the widget.
*/
protected evtDragEnter(event: Drag.Event): void {
if (event.mimeData.hasData(CONTENTS_MIME)) {
const index = Private.hitTestNodes(this._items, event);
if (index === -1) {
return;
}
const item = this._sortedItems[index];
if (item.type !== 'directory' || this.selection[item.path]) {
return;
}
const target = event.target as HTMLElement;
target.classList.add(DROP_TARGET_CLASS);
event.preventDefault();
event.stopPropagation();
}
}
/**
* Handle the `'lm-dragleave'` event for the widget.
*/
protected evtDragLeave(event: Drag.Event): void {
event.preventDefault();
event.stopPropagation();
const dropTarget = DOMUtils.findElement(this.node, DROP_TARGET_CLASS);
if (dropTarget) {
dropTarget.classList.remove(DROP_TARGET_CLASS);
}
}
/**
* Handle the `'lm-dragover'` event for the widget.
*/
protected evtDragOver(event: Drag.Event): void {
event.preventDefault();
event.stopPropagation();
event.dropAction = event.proposedAction;
const dropTarget = DOMUtils.findElement(this.node, DROP_TARGET_CLASS);
if (dropTarget) {
dropTarget.classList.remove(DROP_TARGET_CLASS);
}
const index = Private.hitTestNodes(this._items, event);
if (index === -1 || index >= this._items.length) {
event.dropAction = 'none';
return;
}
this._items[index].classList.add(DROP_TARGET_CLASS);
}
/**
* Handle the `'lm-drop'` event for the widget.
*/
protected evtDrop(event: Drag.Event): void {
event.preventDefault();
event.stopPropagation();
clearTimeout(this._selectTimer);
if (event.proposedAction === 'none') {
event.dropAction = 'none';
return;
}
if (!event.mimeData.hasData(CONTENTS_MIME)) {
return;
}
let target = event.target as HTMLElement;
while (target && target.parentElement) {
if (target.classList.contains(DROP_TARGET_CLASS)) {
target.classList.remove(DROP_TARGET_CLASS);
break;
}
target = target.parentElement;
}
// Get the path based on the target node.
const index = ArrayExt.firstIndexOf(this._items, target);
const items = this._sortedItems;
let basePath = this._model.path;
if (items[index].type === 'directory') {
basePath = PathExt.join(basePath, items[index].name);
}
const manager = this._manager;
// Handle the items.
const promises: Promise<Contents.IModel | null>[] = [];
const paths = event.mimeData.getData(CONTENTS_MIME) as string[];
if (event.ctrlKey && event.proposedAction === 'move') {
event.dropAction = 'copy';
} else {
event.dropAction = event.proposedAction;
}
for (const path of paths) {
const localPath = manager.services.contents.localPath(path);
const name = PathExt.basename(localPath);
const newPath = PathExt.join(basePath, name);
// Skip files that are not moving.
if (newPath === path) {
continue;
}
if (event.dropAction === 'copy') {
promises.push(manager.copy(path, basePath));
} else {
promises.push(renameFile(manager, path, newPath));
}
}
Promise.all(promises).catch(error => {
void showErrorMessage(
this._trans._p('showErrorMessage', 'Error while copying/moving files'),
error
);
});
}
/**
* Start a drag event.
*/
private _startDrag(index: number, clientX: number, clientY: number): void {
let selectedPaths = Object.keys(this.selection);
const source = this._items[index];
const items = this._sortedItems;
let selectedItems: Iterable<Contents.IModel>;
let item: Contents.IModel | undefined;
// If the source node is not selected, use just that node.
if (!source.classList.contains(SELECTED_CLASS)) {
item = items[index];
selectedPaths = [item.path];
selectedItems = [item];
} else {
const path = selectedPaths[0];
item = items.find(value => value.path === path);
selectedItems = this.selectedItems();
}
if (!item) {
return;
}
// Create the drag image.
const ft = this._manager.registry.getFileTypeForModel(item);
const dragImage = this.renderer.createDragImage(
source,
selectedPaths.length,
this._trans,
ft
);
// Set up the drag event.
this._drag = new Drag({
dragImage,
mimeData: new MimeData(),
supportedActions: 'move',
proposedAction: 'move'
});
this._drag.mimeData.setData(CONTENTS_MIME, selectedPaths);
// Add thunks for getting mime data content.
// We thunk the content so we don't try to make a network call
// when it's not needed. E.g. just moving files around
// in a filebrowser
const services = this.model.manager.services;
for (const item of selectedItems) {
this._drag.mimeData.setData(CONTENTS_MIME_RICH, {
model: item,
withContent: async () => {
return await services.contents.get(item.path);
}
} as DirListing.IContentsThunk);
}
if (item.type !== 'directory') {
const otherPaths = selectedPaths.slice(1).reverse();
this._drag.mimeData.setData(FACTORY_MIME, () => {
if (!item) {
return;
}
const path = item.path;
let widget = this._manager.findWidget(path);
if (!widget) {
widget = this._manager.open(item.path);
}
if (otherPaths.length) {
const firstWidgetPlaced = new PromiseDelegate<void>();
void firstWidgetPlaced.promise.then(() => {
let prevWidget = widget;
otherPaths.forEach(path => {
const options: DocumentRegistry.IOpenOptions = {
ref: prevWidget?.id,
mode: 'tab-after'
};
prevWidget = this._manager.openOrReveal(
path,
void 0,
void 0,
options
);
this._manager.openOrReveal(item!.path);
});
});
firstWidgetPlaced.resolve(void 0);
}
return widget;
});
}
// Start the drag and remove the mousemove and mouseup listeners.
document.removeEventListener('mousemove', this, true);
document.removeEventListener('mouseup', this, true);
clearTimeout(this._selectTimer);
void this._drag.start(clientX, clientY).then(action => {
this._drag = null;
clearTimeout(this._selectTimer);
});
}
/**
* Handle selection on a file node.
*/
protected handleFileSelect(event: MouseEvent): void {
// Fetch common variables.
const items = this._sortedItems;
const index = Private.hitTestNodes(this._items, event);
clearTimeout(this._selectTimer);
if (index === -1) {
return;
}
// Clear any existing soft selection.
this._softSelection = '';
const path = items[index].path;
const selected = Object.keys(this.selection);
const isLeftClickOnCheckbox =
event.button === 0 &&
// On Mac, a left-click with the ctrlKey is treated as a right-click.
!(IS_MAC && event.ctrlKey) &&
this.isWithinCheckboxHitArea(event);
// Handle toggling.
if (
(IS_MAC && event.metaKey) ||
(!IS_MAC && event.ctrlKey) ||
isLeftClickOnCheckbox
) {
if (this.selection[path]) {
delete this.selection[path];
} else {
this.selection[path] = true;
}
this._selectionChanged.emit();
this._focusItem(index);
// Handle multiple select.
} else if (event.shiftKey) {
this._handleMultiSelect(index);
this._focusItem(index);
// Handle a 'soft' selection
} else if (path in this.selection && selected.length > 1) {
this._softSelection = path;
// Default to selecting the only the item.
} else {
// Select only the given item.
return this._selectItem(index, false, true);
}
this.update();
}
/**
* (Re-)focus an item in the directory listing.
*
* @param index The index of the item node to focus
*/
private _focusItem(index: number): void {
const items = this._items;
if (items.length === 0) {
// Focus the top node if the folder is empty and therefore there are no
// items inside the folder to focus.
this._focusIndex = 0;
this._focusPath = '';
this.node.focus();
return;
}
const clampedIndex = Math.min(Math.max(index, 0), items.length - 1);
this._focusIndex = clampedIndex;
this._focusPath = this._sortedItems[clampedIndex]?.path ?? '';
const node = items[clampedIndex];
const nameNode = this.renderer.getNameNode(node);
if (nameNode) {
// Make the filename text node focusable so that it receives keyboard
// events; text node was specifically chosen to receive shortcuts because
// it gets substituted with input element during file name edits which
// conveniently deactivates irrelevant shortcuts.
nameNode.tabIndex = 0;
nameNode.focus();
}
}
/**
* Are all of the items between two provided indices selected?
*
* The items at the indices are not considered.
*
* @param j Index of one item.
* @param k Index of another item. Note: may be less or greater than first
* index.
* @returns True if and only if all items between the j and k are selected.
* Returns undefined if j and k are the same.
*/
private _allSelectedBetween(j: number, k: number): boolean | void {
if (j === k) {
return;
}
const [start, end] = j < k ? [j + 1, k] : [k + 1, j];
return this._sortedItems
.slice(start, end)
.reduce((result, item) => result && this.selection[item.path], true);
}
/**
* Handle a multiple select on a file item node.
*/
private _handleMultiSelect(index: number): void {
const items = this._sortedItems;
const fromIndex = this._focusIndex;
const target = items[index];
let shouldAdd = true;
if (index === fromIndex) {
// This follows the convention in Ubuntu and Windows, which is to allow
// the focussed item to gain but not lose selected status on shift-click.
// (MacOS is irrelevant here because MacOS Finder has no notion of a
// focused-but-not-selected state.)
if (!this.selection[target.path]) {
this.selection[target.path] = true;
this._selectionChanged.emit();
}
return;
}
// If the target and all items in-between are selected, then we assume that
// the user is trying to shrink rather than grow the group of selected
// items.
if (this.selection[target.path]) {
// However, there is a special case when the distance between the from-
// and to- index is just one (for example, when the user is pressing the
// shift key plus arrow-up/down). If and only if the situation looks like
// the following when going down (or reverse when going up) ...
//
// - [ante-anchor / previous item] unselected (or boundary)
// - [anchor / currently focussed item / item at from-index] selected
// - [target / next item / item at to-index] selected
//
// ... then we shrink the selection / unselect the currently focussed
// item.
if (Math.abs(index - fromIndex) === 1) {
const anchor = items[fromIndex];
const anteAnchor = items[fromIndex + (index < fromIndex ? 1 : -1)];
if (
// Currently focussed item is selected
this.selection[anchor.path] &&
// Item on other side of focussed item (away from target) is either a
// boundary or unselected
(!anteAnchor || !this.selection[anteAnchor.path])
) {
delete this.selection[anchor.path];
this._selectionChanged.emit();
}
} else if (this._allSelectedBetween(fromIndex, index)) {
shouldAdd = false;
}
}
// Select (or unselect) the rows between chosen index (target) and the last
// focussed.
const step = fromIndex < index ? 1 : -1;
let selectionModified = false;
for (let i = fromIndex; i !== index + step; i += step) {
if (shouldAdd) {
if (i === fromIndex) {
// Do not change the selection state of the starting (fromIndex) item.
continue;
}
if (!this.selection[items[i].path]) {
this.selection[items[i].path] = true;
selectionModified = true;
}
} else {
if (i === index) {
// Do not unselect the target item.
continue;
}
if (this.selection[items[i].path]) {
delete this.selection[items[i].path];
selectionModified = true;
}
}
}
if (selectionModified) {
this._selectionChanged.emit();
}
}
/**
* Copy the selected items, and optionally cut as well.
*/
private _copy(): void {
this._clipboard.length = 0;
for (const item of this.selectedItems()) {
this._clipboard.push(item.path);
}
}
/**
* Delete the files with the given paths.
*/
private async _delete(paths: string[]): Promise<void> {
await Promise.all(
paths.map(path =>
this._model.manager.deleteFile(path).catch(err => {
void showErrorMessage(
this._trans._p('showErrorMessage', 'Delete Failed'),
err
);
})
)
);
}
/**
* Allow the user to rename item on a given row.
*/
private async _doRename(): Promise<string> {
this._inRename = true;
const selectedPaths = Object.keys(this.selection);
// Bail out if nothing has been selected.
if (selectedPaths.length === 0) {
this._inRename = false;
return Promise.resolve('');
}
// Figure out which selected path to use for the rename.
const items = this._sortedItems;
let { path } = items[this._focusIndex];
if (!this.selection[path]) {
// If the currently focused item is not selected, then choose the last
// selected item.
path = selectedPaths.slice(-1)[0];
}
// Get the corresponding model, nodes, and file name.
const index = ArrayExt.findFirstIndex(items, value => value.path === path);
const row = this._items[index];
const item = items[index];
const nameNode = this.renderer.getNameNode(row);
const original = item.name;
// Seed the text input with current file name, and select and focus it.
this._editNode.value = original;
this._selectItem(index, false, true);
// Wait for user input
const newName = await Private.userInputForRename(
nameNode,
this._editNode,
original
);
// Check if the widget was disposed during the `await`.
if (this.isDisposed) {
this._inRename = false;
throw new Error('File browser is disposed.');
}
let finalFilename = newName;
if (!newName || newName === original) {
finalFilename = original;
} else if (!isValidFileName(newName)) {
void showErrorMessage(
this._trans.__('Rename Error'),
Error(
this._trans._p(
'showErrorMessage',
'"%1" is not a valid name for a file. Names must have nonzero length, and cannot include "/", "\\", or ":"',
newName
)
)
);
finalFilename = original;
} else {
// Attempt rename at the file system level.
const manager = this._manager;
const oldPath = PathExt.join(this._model.path, original);
const newPath = PathExt.join(this._model.path, newName);
try {
await renameFile(manager, oldPath, newPath);
} catch (error) {
if (error !== 'File not renamed') {
void showErrorMessage(
this._trans._p('showErrorMessage', 'Rename Error'),
error
);
}
finalFilename = original;
}
// Check if the widget was disposed during the `await`.
if (this.isDisposed) {
this._inRename = false;
throw new Error('File browser is disposed.');
}
}
// If nothing else has been selected, then select the renamed file. In
// other words, don't select the renamed file if the user has clicked
// away to some other file.
if (
!this.isDisposed &&
Object.keys(this.selection).length === 1 &&
// We haven't updated the instance yet to reflect the rename, so unless
// the user or something else has updated the selection, the original file
// path and not the new file path will be in `this.selection`.
this.selection[item.path]
) {
try {
await this._selectItemByName(finalFilename, true, true);
} catch {
// do nothing
console.warn('After rename, failed to select file', finalFilename);
}
}
this._inRename = false;
return finalFilename;
}
/**
* Select a given item.
*/
private _selectItem(
index: number,
keepExisting: boolean,
focus: boolean = true
) {
// Selected the given row(s)
const items = this._sortedItems;
if (!keepExisting) {
this.clearSelectedItems();
}
const path = items[index].path;
this.selection[path] = true;
this._selectionChanged.emit();
if (focus) {
this._focusItem(index);
}
this.update();
}
/**
* Handle the `refreshed` signal from the model.
*/
private _onModelRefreshed(): void {
// Update the selection.
const existing = Object.keys(this.selection);
this.clearSelectedItems(false);
for (const item of this._model.items()) {
const path = item.path;
if (existing.indexOf(path) !== -1) {
this.selection[path] = true;
}
}
if (this.isVisible) {
// Update the sorted items.
this.sort(this.sortState);
} else {
this._isDirty = true;
}
}
/**
* Handle a `pathChanged` signal from the model.
*/
private _onPathChanged(): void {
// Check if the directory listing (or any of its children) has focus.
const hasFocus = this.node.contains(document.activeElement);
// Reset the selection.
this.clearSelectedItems();
// Update the sorted items.
this.sort(this.sortState);
// Reset focus. But wait until the DOM has been updated (hence
// `requestAnimationFrame`).
requestAnimationFrame(() => {
if (this.isDisposed) {
return;
}
// Only focus the first item if the listing (or a child) previously had focus.
if (hasFocus) {
this._focusItem(0);
} else {
// Otherwise, just reset the internal focus index without moving DOM focus.
this._focusIndex = 0;
}
});
}
/**
* Handle a `fileChanged` signal from the model.
*/
private _onFileChanged(
sender: FilterFileBrowserModel,
args: Contents.IChangedArgs
) {
const newValue = args.newValue;
if (!newValue) {
return;
}
const name = newValue.name;
if (args.type !== 'new' || !name) {
return;
}
void this.selectItemByName(name).catch(() => {
/* Ignore if file does not exist. */
});
}
/**
* Handle an `activateRequested` signal from the manager.
*/
private _onActivateRequested(sender: IDocumentManager, args: string): void {
const dirname = PathExt.dirname(args);
if (dirname !== this._model.path) {
return;
}
const basename = PathExt.basename(args);
this.selectItemByName(basename).catch(() => {
/* Ignore if file does not exist. */
});
}
protected translator: ITranslator;
protected _model: FilterFileBrowserModel;
private _trans: TranslationBundle;
private _editNode: HTMLInputElement;
private _items: HTMLElement[] = [];
private _sortedItems: Contents.IModel[] = [];
private _sortState: DirListing.ISortState = {
direction: 'ascending',
key: 'name'
};
private _handleOpenFile: (path: string) => void;
private _onItemOpened = new Signal<DirListing, Contents.IModel>(this);
private _selectionChanged = new Signal<DirListing, void>(this);
private _drag: Drag | null = null;
private _dragData: {
pressX: number;
pressY: number;
index: number;
} | null = null;
private _resizeData: {
/**
* Cursor position when the resize started.
*/
pressX: number;
/**
* Identifier of the column being resized.
*/
column: DirListing.ResizableColumn;
/**
* Size of the column when the cursor grabbed the resize handle.
*/
initialSize: number;
/**
* The disposable to clear the cursor override and resize handle.
*/
readonly overrides: IDisposable;
} | null = null;
private _selectTimer = -1;
private _isCut = false;
private _prevPath = '';
private _clipboard: string[] = [];
private _manager: IDocumentManager;
private _softSelection = '';
protected selection: { [key: string]: boolean } = Object.create(null);
private _renderer: DirListing.IRenderer;
private _searchPrefix: string = '';
private _searchPrefixTimer = -1;
private _inRename = false;
private _isDirty = false;
private _hiddenColumns = new Set<DirListing.ToggleableColumn>();
private _columnSizes: Record<DirListing.IColumn['id'], number | null> = {
name: null,
file_size: null,
is_selected: null,
last_modified: null,
date_created: null
};
private _sortNotebooksFirst = false;
private _sortFileNamesNaturally = true;
private _allowSingleClick = false;
private _allowDragDropUpload = true;
// _focusIndex should never be set outside the range [0, this._items.length - 1]
private _focusIndex = 0;
private _focusPath = '';
private _modifiedStyle: Time.HumanStyle = 'short';
private _createdStyle: Time.HumanStyle = 'short';
private _allUploaded = new Signal<DirListing, void>(this);
private _width: number | null = null;
private _state: IStateDB | null = null;
private _contentScrollbarWidth: number = 0;
private _contentSizeObserver = new ResizeObserver(
this._onContentResize.bind(this)
);
private _paddingWidth: number = 0;
private _handleWidth: number = DEFAULT_HANDLE_WIDTH;
private _lastRenderedState = new WeakMap<HTMLElement, string>();
}
/**
* The namespace for the `DirListing` class statics.
*/
export namespace DirListing {
/**
* An options object for initializing a file browser directory listing.
*/
export interface IOptions {
/**
* A file browser model instance.
*/
model: FilterFileBrowserModel;
/**
* A renderer for file items.
*
* The default is a shared `Renderer` instance.
*/
renderer?: IRenderer;
/**
* A language translator.
*/
translator?: ITranslator;
/**
* An optional state database. If provided, the widget will restore
* the columns sizes
*/
state?: IStateDB;
/**
* Whether to allow drag and drop upload of files.
* The default is `true`.
*/
allowDragDropUpload?: boolean;
/**
* Callback overriding action performed when user asks to open a file.
* The default is to open the file in the main area if it is not open already, or to reveal it otherwise.
*/
handleOpenFile?: (path: string) => void;
}
/**
* A sort state.
*/
export interface ISortState {
/**
* The direction of sort.
*/
direction: 'ascending' | 'descending';
/**
* The sort key.
*/
key: SortableColumn;
}
/**
* Toggleable columns.
*/
export type ToggleableColumn =
| 'last_modified'
| 'is_selected'
| 'file_size'
| 'date_created';
/**
* Resizable columns.
*/
export type ResizableColumn =
| 'name'
| 'last_modified'
| 'file_size'
| 'date_created';
/**
* Sortable columns.
*/
export type SortableColumn =
| 'name'
| 'last_modified'
| 'file_size'
| 'date_created';
/**
* A file contents model thunk.
*
* Note: The content of the model will be empty.
* To get the contents, call and await the `withContent`
* method.
*/
export interface IContentsThunk {
/**
* The contents model.
*/
model: Contents.IModel;
/**
* Fetches the model with contents.
*/
withContent: () => Promise<Contents.IModel>;
}
/**
* The render interface for file browser listing options.
*/
export interface IRenderer {
/**
* Create the DOM node for a dir listing.
*/
createNode(): HTMLElement;
/**
* Populate and empty header node for a dir listing.
*
* @param node - The header node to populate.
*/
populateHeaderNode(
node: HTMLElement,
translator?: ITranslator,
hiddenColumns?: Set<DirListing.ToggleableColumn>,
columnsSizes?: Record<IColumn['id'], number | null>,
sortState?: ISortState
): void;
/**
* Handle a header click.
*
* @param node - A node populated by [[populateHeaderNode]].
*
* @param event - A click event on the node.
*
* @returns The sort state of the header after the click event.
*/
handleHeaderClick(node: HTMLElement, event: MouseEvent): ISortState | null;
/**
* Create a new item node for a dir listing.
*
* @returns A new DOM node to use as a content item.
*/
createItemNode(
hiddenColumns?: Set<DirListing.ToggleableColumn>,
columnsSizes?: Record<IColumn['id'], number | null>
): HTMLElement;
/**
* Update an item's last modified date.
*
* @param modified - Element containing the file's last modified date.
*
* @param modifiedDate - String representation of the last modified date.
*
* @param modifiedStyle - The date style for the modified column: narrow, short, or long
*/
updateItemModified?(
modified: HTMLElement,
modifiedDate: string,
modifiedStyle: Time.HumanStyle
): void;
/**
* Update an item's created date.
*
* @param created - Element containing the file's created date.
*
* @param createdDate - String representation of the created date.
*
* @param createdStyle - The date style for the created column: narrow, short, or long
*/
updateItemCreated?(
created: HTMLElement,
createdDate: string,
createdStyle: Time.HumanStyle
): void;
/**
* Update an item node to reflect the current state of a model.
*
* @param node - A node created by [[createItemNode]].
*
* @param model - The model object to use for the item state.
*
* @param modifiedStyle - The date style for the modified column: narrow, short, or long
*
* @param fileType - The file type of the item, if applicable.
*/
updateItemNode(
node: HTMLElement,
model: Contents.IModel,
fileType?: DocumentRegistry.IFileType,
translator?: ITranslator,
hiddenColumns?: Set<DirListing.ToggleableColumn>,
selected?: boolean,
modifiedStyle?: Time.HumanStyle,
columnsSizes?: Record<IColumn['id'], number | null>,
createdStyle?: Time.HumanStyle
): void;
/**
* Update size of item nodes, assuming that model has not changed.
*/
updateItemSize?(
node: HTMLElement,
model: Contents.IModel,
modifiedStyle?: Time.HumanStyle,
columnsSizes?: Record<IColumn['id'], number | null>,
createdStyle?: Time.HumanStyle
): void;
/**
* Get the node containing the file name.
*
* @param node - A node created by [[createItemNode]].
*
* @returns The node containing the file name.
*/
getNameNode(node: HTMLElement): HTMLElement;
/**
* Get the checkbox input element node.
*
* Downstream interface implementations,such as jupyterlab-unfold, that
* don't support checkboxes should simply always return null for this
* function.
*
* @param node A node created by [[createItemNode]] or
* [[createHeaderItemNode]]
*
* @returns The checkbox node.
*/
getCheckboxNode: (node: HTMLElement) => HTMLInputElement | null;
/**
* Create an appropriate drag image for an item.
*
* @param node - A node created by [[createItemNode]].
*
* @param count - The number of items being dragged.
*
* @param fileType - The file type of the item, if applicable.
*
* @returns An element to use as the drag image.
*/
createDragImage(
node: HTMLElement,
count: number,
trans: TranslationBundle,
fileType?: DocumentRegistry.IFileType
): HTMLElement;
}
interface IBaseColumn {
/**
* Name of the header class, must be unique among other columns.
*/
className: string;
/**
* Name of the item class, must be unique among other columns.
*/
itemClassName: string;
/**
* Minimum size the column should occupy.
*/
minWidth: number;
/**
* Unitless number representing the proportion by which the column
* should grow when the listing is resized.
*/
grow: number;
}
interface IFixedColumn extends IBaseColumn {
id: 'is_selected';
resizable: false;
sortable: false;
grow: 0;
}
/**
* Sortable column.
*/
export interface ISortableColumn extends IBaseColumn {
id: SortableColumn;
sortable: true;
caretSide: 'left' | 'right';
}
/**
* Resizable column.
*/
export interface IResizableColumn extends IBaseColumn {
id: ResizableColumn;
resizable: true;
}
/**
* Columns types supported by DirListing.
*/
export type IColumn =
| IFixedColumn
| ISortableColumn
| IResizableColumn
| (ISortableColumn & IResizableColumn);
/**
* Column definitions.
*/
export const columns: IColumn[] = [
{
id: 'is_selected' as const,
className: CHECKBOX_WRAPPER_CLASS,
itemClassName: CHECKBOX_WRAPPER_CLASS,
minWidth: 18,
resizable: false,
sortable: false,
grow: 0
},
{
id: 'name' as const,
className: NAME_ID_CLASS,
itemClassName: ITEM_NAME_COLUMN_CLASS,
minWidth: 60,
resizable: true,
sortable: true,
caretSide: 'right',
grow: 3
},
{
id: 'last_modified' as const,
className: MODIFIED_ID_CLASS,
itemClassName: ITEM_MODIFIED_CLASS,
minWidth: 60,
resizable: true,
sortable: true,
caretSide: 'left',
grow: 1
},
{
id: 'date_created' as const,
className: CREATED_ID_CLASS,
itemClassName: ITEM_CREATED_CLASS,
minWidth: 60,
resizable: true,
sortable: true,
caretSide: 'left',
grow: 1
},
{
id: 'file_size' as const,
className: FILE_SIZE_ID_CLASS,
itemClassName: ITEM_FILE_SIZE_CLASS,
minWidth: 60,
resizable: true,
sortable: true,
caretSide: 'left',
grow: 0.5
}
];
/**
* The default implementation of an `IRenderer`.
*/
export class Renderer implements IRenderer {
/**
* Create the DOM node for a dir listing.
*/
createNode(): HTMLElement {
const node = document.createElement('div');
const header = document.createElement('div');
const content = document.createElement('ul');
// Allow the node to scroll while dragging items.
content.setAttribute('data-lm-dragscroll', 'true');
content.className = CONTENT_CLASS;
header.className = HEADER_CLASS;
node.appendChild(header);
node.appendChild(content);
// Set to -1 to allow calling this.node.focus().
node.tabIndex = -1;
return node;
}
/**
* Populate and empty header node for a dir listing.
*
* @param node - The header node to populate.
*/
populateHeaderNode(
node: HTMLElement,
translator?: ITranslator,
hiddenColumns?: Set<DirListing.ToggleableColumn>,
columnsSizes?: Record<DirListing.IColumn['id'], number | null>,
sortState?: DirListing.ISortState
): void {
translator = translator || nullTranslator;
const trans = translator.load('jupyterlab');
const elementCreators = {
name: () => this.createHeaderItemNode(trans.__('Name')),
last_modified: () =>
this._createHeaderItemNodeWithSizes({
small: trans.__('Modified'),
large: trans.__('Last Modified')
}),
file_size: () =>
this._createHeaderItemNodeWithSizes({
small: trans.__('Size'),
large: trans.__('File Size')
}),
date_created: () =>
this._createHeaderItemNodeWithSizes({
small: trans.__('Created'),
large: trans.__('Date Created')
}),
is_selected: () =>
this.createCheckboxWrapperNode({
alwaysVisible: true,
headerNode: true
})
};
const visibleColumns = columns.filter(
column => column.id === 'name' || !hiddenColumns?.has(column.id)
);
for (const column of visibleColumns) {
const createElement = elementCreators[column.id];
const element = createElement();
element.classList.add(column.className);
const isLastVisible =
column.id === visibleColumns[visibleColumns.length - 1].id;
if (columnsSizes) {
const size = columnsSizes[column.id];
if (!isLastVisible) {
element.style.width = size + 'px';
}
}
node.appendChild(element);
if (Private.isResizable(column) && !isLastVisible) {
const resizer = document.createElement('div');
resizer.classList.add(RESIZE_HANDLE_CLASS);
resizer.dataset.column = column.id;
node.appendChild(resizer);
}
}
this._updateSortIndicator(
node,
sortState ?? { direction: 'ascending', key: 'name' }
);
}
/**
* Handle a header click.
*
* @param node - A node populated by [[populateHeaderNode]].
*
* @param event - A click event on the node.
*
* @returns The sort state of the header after the click event.
*/
handleHeaderClick(node: HTMLElement, event: MouseEvent): ISortState | null {
const target = event.target as HTMLElement;
const sortableColumns = DirListing.columns.filter(Private.isSortable);
for (const column of sortableColumns) {
const header = node.querySelector(`.${column.className}`);
if (!header) {
continue;
}
if (header.contains(target)) {
const isSelected = header.classList.contains(SELECTED_CLASS);
const isDescending = header.classList.contains(DESCENDING_CLASS);
let direction: 'ascending' | 'descending' = 'ascending';
if (isSelected && !isDescending) {
direction = 'descending';
}
const state: ISortState = { direction, key: column.id };
this._updateSortIndicator(node, state);
return state;
}
}
return { direction: 'ascending', key: 'name' };
}
private _updateSortIndicator(
node: HTMLElement,
sortState: DirListing.ISortState
): void {
const sortableColumns = DirListing.columns.filter(Private.isSortable);
for (const column of sortableColumns) {
const header = node.querySelector(`.${column.className}`);
if (!header) {
continue;
}
const headerIcon = DOMUtils.findElement(
header as HTMLElement,
HEADER_ITEM_ICON_CLASS
);
if (column.id === sortState.key) {
header.classList.add(SELECTED_CLASS);
if (sortState.direction === 'descending') {
header.classList.add(DESCENDING_CLASS);
Private.updateCaret(headerIcon, column.caretSide, 'down');
} else {
header.classList.remove(DESCENDING_CLASS);
Private.updateCaret(headerIcon, column.caretSide, 'up');
}
} else {
header.classList.remove(SELECTED_CLASS);
header.classList.remove(DESCENDING_CLASS);
Private.updateCaret(headerIcon, column.caretSide);
}
}
}
/**
* Create a new item node for a dir listing.
*
* @returns A new DOM node to use as a content item.
*/
createItemNode(
hiddenColumns?: Set<DirListing.ToggleableColumn>,
columnsSizes?: Record<DirListing.IColumn['id'], number | null>
): HTMLElement {
const node = document.createElement('li');
for (const column of columns) {
if (column.id != 'name' && hiddenColumns?.has(column.id)) {
continue;
}
const createElement = this.itemFactories[column.id];
const element = createElement();
node.appendChild(element);
if (columnsSizes) {
const size = columnsSizes[column.id];
element.style.width = size + 'px';
}
}
return node;
}
/**
* Creates a node containing a checkbox.
*
* We wrap the checkbox in a label element in order to increase its hit
* area. This is because the padding of the checkbox itself cannot be
* increased via CSS, as the CSS/form compatibility table at the following
* url from MDN shows:
* https://developer.mozilla.org/en-US/docs/Learn/Forms/Property_compatibility_table_for_form_controls#check_boxes_and_radio_buttons
*
* @param [options]
* @params options.alwaysVisible Should the checkbox be visible even when
* not hovered?
* @returns A new DOM node that contains a checkbox.
*/
createCheckboxWrapperNode(options?: {
alwaysVisible: boolean;
headerNode?: boolean;
}): HTMLElement {
// Wrap the checkbox in a label element in order to increase its hit area.
const labelWrapper = document.createElement('label');
labelWrapper.classList.add(CHECKBOX_WRAPPER_CLASS);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
// Prevent the user from clicking (via mouse, keyboard, or touch) the
// checkbox since other code handles the mouse and keyboard events and
// controls the checked state of the checkbox.
if (!options?.headerNode) {
checkbox.addEventListener('click', event => {
event.preventDefault();
});
}
// The individual file checkboxes are visible on hover, but the header
// check-all checkbox is always visible.
if (options?.alwaysVisible) {
labelWrapper.classList.add('jp-mod-visible');
} else {
// Disable tabbing to all other checkboxes.
checkbox.tabIndex = -1;
}
labelWrapper.appendChild(checkbox);
return labelWrapper;
}
/**
* Update an item's last modified date.
*
* @param modified - Element containing the file's last modified date.
*
* @param modifiedDate - String representation of the last modified date.
*
* @param modifiedStyle - The date style for the modified column: narrow, short, or long
*/
updateItemModified(
modified: HTMLElement,
modifiedDate: string,
modifiedStyle: Time.HumanStyle
): void {
this._updateItemDate(
modified,
modifiedDate,
modifiedStyle,
this._modifiedColumnLastUpdate
);
}
/**
* Update an item's created date.
*
* @param created - Element containing the file's created date.
*
* @param createdDate - String representation of the created date.
*
* @param createdStyle - The date style for the created column: narrow, short, or long
*/
updateItemCreated(
created: HTMLElement,
createdDate: string,
createdStyle: Time.HumanStyle
): void {
this._updateItemDate(
created,
createdDate,
createdStyle,
this._createdColumnLastUpdate
);
}
/**
* Shared implementation for updating a date column element.
*
* Formatting dates is expensive (0.1-0.2ms per call, so over 150 files
* can easily already choke the renderer), so we do the bare minimum check
* of comparing if an update is needed using a last update cache.
*
* @param element - The date column element to update.
*
* @param dateString - String representation of the date.
*
* @param style - The date style: narrow, short, or long.
*
* @param cache - The WeakMap cache tracking the last rendered state for
* this column.
*/
private _updateItemDate(
element: HTMLElement,
dateString: string,
style: Time.HumanStyle,
cache: WeakMap<HTMLElement, { date: string; style: Time.HumanStyle }>
): void {
const previousUpdate = cache.get(element);
if (
previousUpdate?.date === dateString &&
previousUpdate?.style === style
) {
return;
}
const parsedDate = new Date(dateString);
// Render the date in one of multiple formats, depending on the container's size
element.textContent = Time.formatHuman(parsedDate, style);
element.title = Time.format(parsedDate);
cache.set(element, { date: dateString, style });
}
/**
* Update an item node to reflect the current state of a model.
*
* @param node - A node created by [[createItemNode]].
*
* @param model - The model object to use for the item state.
*
* @param fileType - The file type of the item, if applicable.
*
*/
updateItemNode(
node: HTMLElement,
model: Contents.IModel,
fileType?: DocumentRegistry.IFileType,
translator?: ITranslator,
hiddenColumns?: Set<DirListing.ToggleableColumn>,
selected?: boolean,
modifiedStyle?: Time.HumanStyle,
columnsSizes?: Record<DirListing.IColumn['id'], number | null>,
createdStyle?: Time.HumanStyle
): void {
if (selected) {
node.classList.add(SELECTED_CLASS);
}
fileType =
fileType || DocumentRegistry.getDefaultTextFileType(translator);
const { icon, iconClass, name } = fileType;
translator = translator || nullTranslator;
const trans = translator.load('jupyterlab');
const prevState = this._lastRenderedState.get(node);
const newState = JSON.stringify({
name: model.name,
selected,
lastModified: model.last_modified,
created: model.created,
modifiedStyle,
createdStyle,
hiddenColumns: Array.from(hiddenColumns ?? []).sort(),
columnsSizes,
fileSize: model.size
});
const checkboxWrapper = DOMUtils.findElement(
node,
CHECKBOX_WRAPPER_CLASS
);
const checkbox = checkboxWrapper?.querySelector(
'input[type="checkbox"]'
) as HTMLInputElement | undefined;
if (checkbox) checkbox.checked = selected ?? false;
if (prevState === newState) return;
this._lastRenderedState.set(node, newState);
const iconContainer = DOMUtils.findElement(node, ITEM_ICON_CLASS);
const text = DOMUtils.findElement(node, ITEM_TEXT_CLASS);
const nameColumn = DOMUtils.findElement(node, ITEM_NAME_COLUMN_CLASS);
let modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS) as
| HTMLElement
| undefined;
let fileSize = DOMUtils.findElement(node, ITEM_FILE_SIZE_CLASS) as
| HTMLElement
| undefined;
let created = DOMUtils.findElement(node, ITEM_CREATED_CLASS) as
| HTMLElement
| undefined;
const showFileCheckboxes = !hiddenColumns?.has('is_selected');
if (checkboxWrapper && !showFileCheckboxes) {
node.removeChild(checkboxWrapper);
} else if (showFileCheckboxes && !checkboxWrapper) {
const checkboxWrapper = this.createCheckboxWrapperNode();
nameColumn.insertAdjacentElement('beforebegin', checkboxWrapper);
}
const showModified = !hiddenColumns?.has('last_modified');
if (modified && !showModified) {
node.removeChild(modified);
modified = undefined;
} else if (showModified && !modified) {
modified = this.itemFactories.last_modified();
nameColumn.insertAdjacentElement('afterend', modified);
}
const showCreated = !hiddenColumns?.has('date_created');
if (created && !showCreated) {
node.removeChild(created);
created = undefined;
} else if (showCreated && !created) {
created = this.itemFactories.date_created();
(modified ?? nameColumn).insertAdjacentElement('afterend', created);
}
const showFileSize = !hiddenColumns?.has('file_size');
if (fileSize && !showFileSize) {
node.removeChild(fileSize);
} else if (showFileSize && !fileSize) {
fileSize = this.itemFactories.file_size();
const insertAfter = created ?? modified ?? nameColumn;
if (insertAfter) {
insertAfter.insertAdjacentElement('afterend', fileSize);
}
}
// render the file item's icon
requestAnimationFrame(() => {
LabIcon.resolveElement({
icon,
iconClass: classes(iconClass, 'jp-Icon'),
container: iconContainer,
className: ITEM_ICON_CLASS,
stylesheet: 'listing'
});
});
let hoverText = trans.__('Name: %1', model.name);
// add file size to pop up if its available
if (model.size !== null && model.size !== undefined) {
const fileSizeText = formatFileSize(model.size, 1, 1024);
if (fileSize) {
fileSize.textContent = fileSizeText;
}
hoverText += trans.__(
'\nSize: %1',
formatFileSize(model.size, 1, 1024)
);
} else if (fileSize) {
fileSize.textContent = '';
}
if (model.path) {
const dirname = PathExt.dirname(model.path);
if (dirname) {
hoverText += trans.__('\nPath: %1', dirname.substr(0, 50));
if (dirname.length > 50) {
hoverText += '...';
}
}
}
if (model.created) {
hoverText += trans.__(
'\nCreated: %1',
Time.format(new Date(model.created))
);
}
if (model.last_modified) {
hoverText += trans.__(
'\nModified: %1',
Time.format(new Date(model.last_modified))
);
}
hoverText += trans.__('\nWritable: %1', model.writable);
node.title = hoverText;
node.setAttribute('data-file-type', name);
if (model.name.startsWith('.')) {
node.setAttribute('data-is-dot', 'true');
} else {
node.removeAttribute('data-is-dot');
}
// If an item is being edited currently, its text node is unavailable.
const indices = !model.indices ? [] : model.indices;
let highlightedName = StringExt.highlight(model.name, indices, h.mark);
if (text) {
VirtualDOM.render(h.span(highlightedName), text);
}
// Adds an aria-label to the checkbox element.
if (checkbox) {
let ariaLabel: string;
if (fileType.contentType === 'directory') {
ariaLabel = selected
? trans.__('Deselect directory "%1"', highlightedName)
: trans.__('Select directory "%1"', highlightedName);
} else {
ariaLabel = selected
? trans.__('Deselect file "%1"', highlightedName)
: trans.__('Select file "%1"', highlightedName);
}
checkbox.setAttribute('aria-label', ariaLabel);
checkbox.checked = selected ?? false;
}
this.updateItemSize(
node,
model,
modifiedStyle,
columnsSizes,
createdStyle
);
}
/**
* Update size of item nodes, assuming that model has not changed.
*/
updateItemSize(
node: HTMLElement,
model: Contents.IModel,
modifiedStyle?: Time.HumanStyle,
columnsSizes?: Record<DirListing.IColumn['id'], number | null>,
createdStyle?: Time.HumanStyle
): void {
if (columnsSizes) {
for (const column of columns) {
const element = DOMUtils.findElement(node, column.itemClassName);
if (!element) {
continue;
}
const sizeSpec = columnsSizes[column.id];
const newWidth = sizeSpec === null ? '' : sizeSpec + 'px';
if (newWidth !== element.style.width) {
element.style.width = newWidth;
}
}
}
let modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS) as
| HTMLElement
| undefined;
if (model.last_modified && modified) {
this.updateItemModified(
modified,
model.last_modified,
modifiedStyle ?? 'short'
);
}
let created = DOMUtils.findElement(node, ITEM_CREATED_CLASS) as
| HTMLElement
| undefined;
if (model.created && created) {
this.updateItemCreated(created, model.created, createdStyle ?? 'short');
}
}
/**
* Get the node containing the file name.
*
* @param node - A node created by [[createItemNode]].
*
* @returns The node containing the file name.
*/
getNameNode(node: HTMLElement): HTMLElement {
return DOMUtils.findElement(node, ITEM_TEXT_CLASS);
}
/**
* Get the checkbox input element node.
*
* @param node A node created by [[createItemNode]] or
* [[createHeaderItemNode]]
*
* @returns The checkbox node.
*/
getCheckboxNode(node: HTMLElement): HTMLInputElement | null {
return node.querySelector(
`.${CHECKBOX_WRAPPER_CLASS} input[type=checkbox]`
);
}
/**
* Create a drag image for an item.
*
* @param node - A node created by [[createItemNode]].
*
* @param count - The number of items being dragged.
*
* @param fileType - The file type of the item, if applicable.
*
* @returns An element to use as the drag image.
*/
createDragImage(
node: HTMLElement,
count: number,
trans: TranslationBundle,
fileType?: DocumentRegistry.IFileType
): HTMLElement {
const dragImage = node.cloneNode(true) as HTMLElement;
const icon = DOMUtils.findElement(dragImage, ITEM_ICON_CLASS);
// Hide additional columns from the drag image to keep it unobtrusive.
const extraColumns = DirListing.columns.filter(
column => column.id !== 'name'
);
for (const extraColumn of extraColumns) {
const columnElement = DOMUtils.findElement(
dragImage,
extraColumn.itemClassName
);
if (!columnElement) {
// We can only remove columns which are rendered.
continue;
}
dragImage.removeChild(columnElement);
}
if (!fileType) {
icon.textContent = '';
icon.className = '';
} else {
icon.textContent = fileType.iconLabel || '';
icon.className = fileType.iconClass || '';
}
icon.classList.add(DRAG_ICON_CLASS);
if (count > 1) {
const nameNode = DOMUtils.findElement(dragImage, ITEM_TEXT_CLASS);
nameNode.textContent = trans._n('%1 Item', '%1 Items', count);
}
return dragImage;
}
/**
* Factories for individual parts of the item.
*/
protected itemFactories = {
name: () => {
const name = document.createElement('span');
const icon = document.createElement('span');
const text = document.createElement('span');
icon.className = ITEM_ICON_CLASS;
text.className = ITEM_TEXT_CLASS;
name.className = ITEM_NAME_COLUMN_CLASS;
name.appendChild(icon);
name.appendChild(text);
return name;
},
last_modified: () => {
const modified = document.createElement('span');
modified.className = ITEM_MODIFIED_CLASS;
return modified;
},
file_size: () => {
const fileSize = document.createElement('span');
fileSize.className = ITEM_FILE_SIZE_CLASS;
return fileSize;
},
date_created: () => {
const created = document.createElement('span');
created.className = ITEM_CREATED_CLASS;
return created;
},
is_selected: () => this.createCheckboxWrapperNode()
};
/**
* Create a node for a header item.
*/
protected createHeaderItemNode(label: string): HTMLElement {
const node = document.createElement('div');
const text = document.createElement('span');
const icon = document.createElement('span');
node.className = HEADER_ITEM_CLASS;
text.className = HEADER_ITEM_TEXT_CLASS;
icon.className = HEADER_ITEM_ICON_CLASS;
text.textContent = label;
node.appendChild(text);
node.appendChild(icon);
return node;
}
/**
* Create a node for a header item with multiple sizes.
*/
private _createHeaderItemNodeWithSizes(labels: {
[k: string]: string;
}): HTMLElement {
const node = document.createElement('div');
node.className = HEADER_ITEM_CLASS;
const icon = document.createElement('span');
icon.className = HEADER_ITEM_ICON_CLASS;
for (let k of Object.keys(labels)) {
const text = document.createElement('span');
text.classList.add(
HEADER_ITEM_TEXT_CLASS,
HEADER_ITEM_TEXT_CLASS + '-' + k
);
text.textContent = labels[k];
node.appendChild(text);
}
node.appendChild(icon);
return node;
}
/**
* Register of most recent arguments for last modified column update.
*/
private _modifiedColumnLastUpdate = new WeakMap<
HTMLElement,
{ date: string; style: Time.HumanStyle }
>();
/**
* Register of most recent arguments for date created column update.
*/
private _createdColumnLastUpdate = new WeakMap<
HTMLElement,
{ date: string; style: Time.HumanStyle }
>();
private _lastRenderedState = new WeakMap<HTMLElement, string>();
}
/**
* The default `IRenderer` instance.
*/
export const defaultRenderer = new Renderer();
}
/**
* The namespace for the listing private data.
*/
namespace Private {
/**
* Handle editing text on a node.
*
* @returns Boolean indicating whether the name changed.
*/
export function userInputForRename(
text: HTMLElement,
edit: HTMLInputElement,
original: string
): Promise<string> {
const parent = text.parentElement as HTMLElement;
parent.replaceChild(edit, text);
edit.focus();
const index = edit.value.lastIndexOf('.');
if (index === -1) {
edit.setSelectionRange(0, edit.value.length);
} else {
edit.setSelectionRange(0, index);
}
return new Promise<string>(resolve => {
edit.onblur = () => {
parent.replaceChild(text, edit);
resolve(edit.value);
};
edit.onkeydown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Enter':
event.stopPropagation();
event.preventDefault();
edit.blur();
break;
case 'Escape':
event.stopPropagation();
event.preventDefault();
edit.value = original;
edit.blur();
// Put focus back on the text node. That way the user can, for
// example, press the keyboard shortcut to go back into edit mode,
// and it will work.
text.focus();
break;
default:
break;
}
};
});
}
/**
* Sort a list of items by sort state as a new array.
*/
export function sort(
items: Iterable<Contents.IModel>,
state: DirListing.ISortState,
sortNotebooksFirst: boolean = false,
sortFileNamesNaturally: boolean = true,
translator: ITranslator
): Contents.IModel[] {
const copy = Array.from(items);
const reverse = state.direction === 'descending' ? 1 : -1;
/**
* Compares two items and returns whether they should have a fixed priority.
* The fixed priority enables to always sort the directories above the other files. And to sort the notebook above other files if the `sortNotebooksFirst` is true.
*/
function isPriorityOverridden(a: Contents.IModel, b: Contents.IModel) {
if (sortNotebooksFirst) {
return a.type !== b.type;
}
return (a.type === 'directory') !== (b.type === 'directory');
}
/**
* Returns the priority of a file.
*/
function getPriority(item: Contents.IModel): number {
if (item.type === 'directory') {
return 2;
}
if (item.type === 'notebook' && sortNotebooksFirst) {
return 1;
}
return 0;
}
/**
* Compare two items by their name using `translator.languageCode`, with fallback to `navigator.language`.
* When sortFileNamesNaturally is true, uses natural order.
*/
function compareByName(a: Contents.IModel, b: Contents.IModel) {
// Wokaround for Chromium invalid language code on CI, see
// https://github.com/jupyterlab/jupyterlab/issues/17079
const navigatorLanguage = navigator.language.split('@')[0];
const languageCode = (
translator.languageCode ?? navigatorLanguage
).replace('_', '-');
const localeOptions: Intl.CollatorOptions = {
numeric: sortFileNamesNaturally,
sensitivity: 'base'
};
try {
return a.name.localeCompare(b.name, languageCode, localeOptions);
} catch (e) {
console.warn(
`localeCompare failed to compare ${a.name} and ${b.name} under languageCode: ${languageCode}`
);
return a.name.localeCompare(b.name, navigatorLanguage, localeOptions);
}
}
function compare(
compare: (a: Contents.IModel, b: Contents.IModel) => number
) {
return (a: Contents.IModel, b: Contents.IModel) => {
// Group directory first, then notebooks, then files
if (isPriorityOverridden(a, b)) {
return getPriority(b) - getPriority(a);
}
const compared = compare(a, b);
if (compared !== 0) {
return compared * reverse;
}
// Default sorting is alphabetical ascending
return compareByName(a, b);
};
}
if (state.key === 'last_modified') {
// Sort by last modified
copy.sort(
compare((a: Contents.IModel, b: Contents.IModel) => {
return (
new Date(a.last_modified).getTime() -
new Date(b.last_modified).getTime()
);
})
);
} else if (state.key === 'file_size') {
// Sort by size
copy.sort(
compare((a: Contents.IModel, b: Contents.IModel) => {
return (b.size ?? 0) - (a.size ?? 0);
})
);
} else if (state.key === 'date_created') {
// Sort by date created
copy.sort(
compare((a: Contents.IModel, b: Contents.IModel) => {
return (
new Date(a.created ?? '').getTime() -
new Date(b.created ?? '').getTime()
);
})
);
} else {
// Sort by name
copy.sort(
compare((a: Contents.IModel, b: Contents.IModel) => {
return compareByName(b, a);
})
);
}
return copy;
}
/**
* Check if the column is resizable.
*/
export const isResizable = (
column: DirListing.IColumn
): column is DirListing.IResizableColumn => {
return 'resizable' in column && column.resizable;
};
/**
* Check if the column is sortable.
*/
export const isSortable = (
column: DirListing.IColumn
): column is DirListing.ISortableColumn => {
return 'sortable' in column && column.sortable;
};
/**
* Get the index of the node at a client position, or `-1`.
*/
export function hitTestNodes(
nodes: HTMLElement[],
event: MouseEvent
): number {
return ArrayExt.findFirstIndex(
nodes,
node =>
ElementExt.hitTest(node, event.clientX, event.clientY) ||
event.target === node
);
}
/**
* Update an inline svg caret icon in a node.
*/
export function updateCaret(
container: HTMLElement,
float: 'left' | 'right',
state?: 'down' | 'up' | undefined
): void {
if (state) {
(state === 'down' ? caretDownIcon : caretUpIcon).element({
container,
tag: 'span',
stylesheet: 'listingHeaderItem'
});
if (float === 'left') {
container.style.order = '-1';
} else {
container.style.order = '';
}
} else {
LabIcon.remove(container);
container.className = HEADER_ITEM_ICON_CLASS;
}
}
export async function createDirectory(
manager: IDocumentManager,
path: string,
name: string
): Promise<string> {
const model = await manager.newUntitled({
path: path,
type: 'directory'
});
const tmpDirPath = PathExt.join(path, model.name);
const dirPath = PathExt.join(path, name);
try {
await manager.rename(tmpDirPath, dirPath);
} catch (e) {
// The `dirPath` already exists, remove the temporary new directory
await manager.deleteFile(tmpDirPath);
}
return dirPath;
}
export function isDirectoryEntry(
entry: FileSystemEntry
): entry is FileSystemDirectoryEntry {
return entry.isDirectory;
}
export function isFileEntry(
entry: FileSystemEntry
): entry is FileSystemFileEntry {
return entry.isFile;
}
export function defensiveGetAsEntry(
item: DataTransferItem
): FileSystemEntry | null {
if (item.webkitGetAsEntry) {
return item.webkitGetAsEntry();
}
if ('getAsEntry' in item) {
// See https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry
return (item['getAsEntry'] as () => FileSystemEntry | null)();
}
return null;
}
function readEntries(reader: FileSystemDirectoryReader) {
return new Promise<FileSystemEntry[]>((resolve, reject) =>
reader.readEntries(resolve, reject)
);
}
export function readFile(entry: FileSystemFileEntry) {
return new Promise<File>((resolve, reject) => entry.file(resolve, reject));
}
export async function collectEntries(reader: FileSystemDirectoryReader) {
// Spec requires calling `readEntries` until these are exhausted;
// in practice this is only required in Chromium-based browsers for >100 files.
// https://issues.chromium.org/issues/41110876
const allEntries: FileSystemEntry[] = [];
let done = false;
while (!done) {
const entries = await readEntries(reader);
if (entries.length === 0) {
done = true;
} else {
allEntries.push(...entries);
}
}
return allEntries;
}
}
/**
* Format bytes to human readable string.
*/
export function formatFileSize(
bytes: number,
decimalPoint: number,
k: number
): string {
// https://www.codexworld.com/how-to/convert-file-size-bytes-kb-mb-gb-javascript/
if (bytes === 0) {
return '0 B';
}
const dm = decimalPoint || 2;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
if (i >= 0 && i < sizes.length) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
} else {
return String(bytes);
}
}