UNPKG

@jupyterlab/filebrowser

Version:
1,724 lines (1,549 loc) 87 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { Dialog, DOMUtils, showDialog, showErrorMessage } from '@jupyterlab/apputils'; import { PathExt, Time } from '@jupyterlab/coreutils'; import { IDocumentManager, isValidFileName, renameFile } from '@jupyterlab/docmanager'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { Contents } from '@jupyterlab/services'; import { ITranslator, nullTranslator, TranslationBundle } from '@jupyterlab/translation'; import { caretDownIcon, caretUpIcon, classes, LabIcon } from '@jupyterlab/ui-components'; import { ArrayExt, filter, StringExt } from '@lumino/algorithm'; import { MimeData, PromiseDelegate } from '@lumino/coreutils'; import { ElementExt } from '@lumino/domutils'; import { Drag } from '@lumino/dragdrop'; import { Message, MessageLoop } from '@lumino/messaging'; import { ISignal, Signal } from '@lumino/signaling'; import { h, VirtualDOM } from '@lumino/virtualdom'; import { Widget } from '@lumino/widgets'; import { 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 icon cell. */ const ITEM_ICON_CLASS = 'jp-DirListing-itemIcon'; /** * The class name added to the listing item modified cell. */ const ITEM_MODIFIED_CLASS = 'jp-DirListing-itemModified'; /** * The class name added to the listing item file size cell. */ const ITEM_FILE_SIZE_CLASS = 'jp-DirListing-itemFileSize'; /** * The class name added to the label element that wraps each item's checkbox and * the header's check-all checkbox. */ const CHECKBOX_WRAPPER_CLASS = 'jp-DirListing-checkboxWrapper'; /** * The class name added to the dir listing editor node. */ const EDITOR_CLASS = 'jp-DirListing-editor'; /** * The class name added to the name column header cell. */ const NAME_ID_CLASS = 'jp-id-name'; /** * The class name added to the modified column header cell. */ const MODIFIED_ID_CLASS = 'jp-id-modified'; /** * The class name added to the file size column header cell. */ const FILE_SIZE_ID_CLASS = 'jp-id-filesize'; /** * The class name added to the narrow column header cell. */ const NARROW_ID_CLASS = 'jp-id-narrow'; /** * The class name added to the modified column header cell and modified item cell when hidden. */ const MODIFIED_COLUMN_HIDDEN = 'jp-LastModified-hidden'; /** * The class name added to the size column header cell and size item cell when hidden. */ const FILE_SIZE_COLUMN_HIDDEN = 'jp-FileSize-hidden'; /** * 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 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 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 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 model - The file browser view model. */ 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; const headerNode = DOMUtils.findElement(this.node, HEADER_CLASS); // hide the file size column by default this._hiddenColumns.add('file_size'); this._renderer.populateHeaderNode( headerNode, this.translator, this._hiddenColumns ); this._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; } /** * 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._sortState = state; this.update(); } /** * 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 items = this._sortedItems.filter(item => this.selection[item.path]); if (!items.length) { return; } const message = items.length === 1 ? this._trans.__( 'Are you sure you want to permanently delete: %1?', items[0].name ) : 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 result = await showDialog({ title: this._trans.__('Delete'), body: message, buttons: [ Dialog.cancelButton({ label: this._trans.__('Cancel') }), Dialog.warnButton({ label: this._trans.__('Delete') }) ], // 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 ); }); } /** * 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)) ); } /** * 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(): void { this.selection = Object.create(null); } /** * Select an item by name. * * @param name - The name of the item to select. * @param focus - Whether to move focus to the selected item. * * @returns A promise that resolves when the name is selected. */ async selectItemByName(name: string, focus: boolean = false): Promise<void> { // 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': 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; const content = DOMUtils.findElement(node, CONTENT_CLASS); node.addEventListener('mousedown', this); node.addEventListener('keydown', this); node.addEventListener('click', this); node.addEventListener('dblclick', this); 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); } /** * 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); 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(); } } /** * 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; 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); 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` 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. nameNode.tabIndex = i === this._focusIndex ? 0 : -1; } }); // 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') ); } // Update item nodes based on widget state. items.forEach((item, i) => { const node = nodes[i]; const ft = this._manager.registry.getFileTypeForModel(item); renderer.updateItemNode( node, item, ft, this.translator, this._hiddenColumns, this.selection[item.path] ); 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'); } node.title = this._trans.__('%1\nKernel: %2', node.title, name); } } this._prevPath = this._model.path; } onResize(msg: Widget.ResizeMessage): void { const { width } = msg.width === -1 ? this.node.getBoundingClientRect() : msg; this.toggleClass('jp-DirListing-narrow', width < 250); } 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 ); } /** * 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); } } /** * 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) ); } 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); } } /** * 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) { 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 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.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 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(); // 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._manager.openOrReveal(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; } 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.keyCode) { case 13: { // 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 38: // Up arrow this._handleArrowY(event, -1); return; case 40: // Down arrow this._handleArrowY(event, 1); return; case 32: { // Space 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.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.keyCode === 32) && (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 { const files = event.dataTransfer?.files; if (!files || files.length === 0) { return; } const length = event.dataTransfer?.items.length; if (!length) { return; } for (let i = 0; i < length; i++) { let entry = event.dataTransfer?.items[i].webkitGetAsEntry(); if (entry?.isDirectory) { console.log('currently not supporting drag + drop for folders'); void showDialog({ title: this._trans.__('Error Uploading Folder'), body: this._trans.__( 'Drag and Drop is currently not supported for folders' ), buttons: [Dialog.cancelButton({ label: this._trans.__('Close') })] }); } } event.preventDefault(); for (let i = 0; i < files.length; i++) { void this._model.upload(files[i]); } } /** * 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); 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 && 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._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.node.focus(); return; } this._focusIndex = index; const node = items[index]; 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 t