From 39f0e726cf1c28b40919f8224348f11d8d0e5779 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Fri, 21 Apr 2023 11:00:29 +0100 Subject: [PATCH 01/49] lumino drag-and-drop --- js/src/drag.ts | 1089 +++++++++++++++++++++++++++++++++++++++ js/src/index.tsx | 3 +- js/src/treefinder.ts | 74 ++- js/style/treefinder.css | 6 + 4 files changed, 1167 insertions(+), 5 deletions(-) create mode 100644 js/src/drag.ts diff --git a/js/src/drag.ts b/js/src/drag.ts new file mode 100644 index 00000000..39544ba7 --- /dev/null +++ b/js/src/drag.ts @@ -0,0 +1,1089 @@ +/****************************************************************************** + * + * Copyright (c) 2019, the jupyter-fs authors. + * + * This file is part of the jupyter-fs library, distributed under the terms of + * the Apache License 2.0. The full license can be found in the LICENSE file. + * + */ + +/* +Originally copied from jupyter's nbdime package, under the following license terms: + +This project is licensed under the terms of the Modified BSD License +(also known as New or Revised or 3-Clause BSD), as follows: + +- Copyright (c) 2001-2015, IPython Development Team +- Copyright (c) 2015-, Jupyter Development Team + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the name of the Jupyter Development Team nor the names of its +contributors may be used to endorse or promote products derived from this +software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +## About the Jupyter Development Team + +The Jupyter Development Team is the set of all contributors to the Jupyter project. +This includes all of the Jupyter subprojects. + +The core team that coordinates development on GitHub can be found here: +https://github.com/jupyter/. +*/ + +'use strict'; + +import { + Widget +} from '@lumino/widgets'; + +import type { + Message +} from '@lumino/messaging'; + +import { + MimeData +} from '@lumino/coreutils'; + +import { + Drag, IDragEvent, DropAction, SupportedActions +} from '@lumino/dragdrop'; + + +/** + * The class name added to the DropWidget + */ +const DROP_WIDGET_CLASS = 'jfs-DropWidget'; + +/** + * The class name added to the DragWidget + */ +const DRAG_WIDGET_CLASS = 'jfs-DragWidget'; + +/** + * The class name added to something which can be used to drag a box + */ +const DRAG_HANDLE = 'jfs-mod-dragHandle'; + +/** + * The class name of the default drag handle + */ +const DEFAULT_DRAG_HANDLE_CLASS = 'jfs-DragWidget-dragHandle'; + +/** + * The class name added to a drop target. + */ +const DROP_TARGET_CLASS = 'jfs-mod-dropTarget'; + +/** + * The threshold in pixels to start a drag event. + */ +const DRAG_THRESHOLD = 5; + +/** + * The mime type for a table header drag object. + */ +export const TABLE_HEADER_MIME = 'application/x-jupyterfs-thead'; + + +/** + * Determine whether node is equal to or a descendant of our widget, and that it does + * not belong to a nested drag widget. + */ +export +function belongsToUs(node: HTMLElement, parentClass: string, + parentNode: HTMLElement): boolean { + let candidate: HTMLElement | null = node; + // Traverse DOM until drag widget encountered: + while (candidate && !candidate.classList.contains(parentClass)) { + candidate = candidate.parentElement; + } + return !!candidate && candidate === parentNode; +} + + +/** + * Find the direct child node of `parent`, which has `node` as a descendant. + * Alternatively, parent can be a collection of children. + * + * Returns null if not found. + */ +export +function findChild(parent: HTMLElement | HTMLElement[], node: HTMLElement): HTMLElement | null { + // Work our way up the DOM to an element which has this node as parent + let child: HTMLElement | null = null; + let parentIsArray = Array.isArray(parent); + let isDirectChild = (child: HTMLElement): boolean => { + if (parentIsArray) { + return (parent as HTMLElement[]).indexOf(child) > -1; + } else { + return child.parentElement === parent; + } + }; + let candidate: HTMLElement | null = node; + while (candidate && candidate !== parent) { + if (isDirectChild(candidate)) { + child = candidate; + break; + } + candidate = candidate.parentElement; + } + return child; +} + + +/** + * A widget class which allows the user to drop mime data onto it. + * + * To complete the class, the following functions need to be implemented: + * - processDrop: Process pre-screened drop events + * + * The functionallity of the class can be extended by overriding the following + * functions: + * - findDropTarget(): Override if anything other than the direct children + * of the widget's node are to be the drop targets. + * + * For maximum control, `evtDrop` can be overriden. + */ +export +abstract class DropWidget extends Widget { + /** + * Construct a drop widget. + */ + constructor(options: DropWidget.IOptions={}) { + super(options); + this.acceptDropsFromExternalSource = + options.acceptDropsFromExternalSource === true; + this.addClass(DROP_WIDGET_CLASS); + this.acceptedDropMimeTypes = options.acceptedDropMimeTypes ?? []; + } + + /** + * Whether the widget should accept drops from an external source, + * or only accept drops from itself. + * Defaults to false, which will disallow all drops unless widget + * is also a drag widget. + */ + acceptDropsFromExternalSource: boolean; + + + /** + * Which mimetypes that the widget accepts for drops + */ + acceptedDropMimeTypes: string[]; + + + /** + * Handle the DOM events for the widget. + * + * @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 drop widget's node. It should + * not be called directly by user code. + */ + handleEvent(event: Event): void { + switch (event.type) { + case 'lm-dragenter': + this._evtDragEnter(event as IDragEvent); + break; + case 'lm-dragleave': + this._evtDragLeave(event as IDragEvent); + break; + case 'lm-dragover': + this._evtDragOver(event as IDragEvent); + break; + case 'lm-drop': + this.evtDrop(event as IDragEvent); + break; + default: + break; + } + } + + protected validateSource(event: IDragEvent) { + return this.acceptDropsFromExternalSource || event.source === this; + } + + /** + * Processes a drop event. + * + * This function is called after checking: + * - That the `dropTarget` is a valid drop target + * - The value of `event.source` if `acceptDropsFromExternalSource` is false + */ + protected abstract processDrop(dropTarget: HTMLElement, event: IDragEvent): void; + + /** + * Find a drop target from a given drag event target. + * + * Returns null if no valid drop target was found. + * + * The default implementation returns the direct child that is the parent of + * `node`, or `node` if it is itself a direct child. It also checks that the + * needed mime type is included + */ + protected findDropTarget(input: HTMLElement, mimeData: MimeData): HTMLElement | null { + if (!this.acceptedDropMimeTypes.some(mimetype => mimeData.hasData(mimetype))) { + return null; + } + + if (this._isValidTargetHeader(input, mimeData.getData(TABLE_HEADER_MIME))) { + input.classList.add(DROP_TARGET_CLASS); + } else { + return null; + } + + // No need to findChild for reordering of columns + if (mimeData.types().includes(TABLE_HEADER_MIME)) { + return input; + } else { + return findChild(this.node, input); + } + } + + /** + * Handle the `'lm-drop'` event for the widget. + * + * Responsible for pre-processing event before calling `processDrop`. + * + * Should normally only be overriden if you cannot achive your goal by + * other overrides. + */ + protected evtDrop(event: IDragEvent): void { + 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; + } + if (!target || !belongsToUs(target, DROP_WIDGET_CLASS, this.node)) { + // Ignore event + return; + } + + // If configured to, only accept internal moves: + if (!this.validateSource(event)) { + event.dropAction = 'none'; + event.preventDefault(); + event.stopPropagation(); + return; + } + + this.processDrop(target, event); + } + + /** + * Handle `after_attach` messages for the widget. + */ + protected onAfterAttach(msg: Message): void { + let node = this.node; + node.addEventListener('lm-dragenter', this); + node.addEventListener('lm-dragleave', this); + node.addEventListener('lm-dragover', this); + node.addEventListener('lm-drop', this); + } + + /** + * Handle `before_detach` messages for the widget. + */ + protected onBeforeDetach(msg: Message): void { + let node = this.node; + node.removeEventListener('lm-dragenter', this); + node.removeEventListener('lm-dragleave', this); + node.removeEventListener('lm-dragover', this); + node.removeEventListener('lm-drop', this); + } + + + /** + * Handle the `'lm-dragenter'` event for the widget. + */ + private _evtDragEnter(event: IDragEvent): void { + if (!this.validateSource(event)) { + return; + } + let target = this.findDropTarget(event.target as HTMLElement, event.mimeData); + if (target === null) { + return; + } + this._clearDropTarget(); + target.classList.add(DROP_TARGET_CLASS); + event.preventDefault(); + event.stopPropagation(); + } + + /** + * Handle the `'lm-dragleave'` event for the widget. + */ + private _evtDragLeave(event: IDragEvent): void { + event.preventDefault(); + event.stopPropagation(); + this._clearDropTarget(); + } + + /** + * Handle the `'lm-dragover'` event for the widget. + */ + private _evtDragOver(event: IDragEvent): void { + if (!this.validateSource(event)) { + return; + } + this._clearDropTarget(); + let target = this.findDropTarget(event.target as HTMLElement, event.mimeData); + if (target === null) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dropAction = event.proposedAction; + } + + /** + * Checks if the target is a header, and it is not 'path' or itself + */ + private _isValidTargetHeader(target: HTMLElement, draggedColumn: string) { + return target.classList.contains('tf-header-name') && + target.innerText !== draggedColumn && + target.innerText !== 'path' + } + + /** + * Clear existing drop target from out children. + * + * #### Notes + * This function assumes there are only one active drop target + */ + private _clearDropTarget(): void { + let elements = this.node.getElementsByClassName(DROP_TARGET_CLASS); + if (elements.length) { + (elements[0] as HTMLElement).classList.remove(DROP_TARGET_CLASS); + } + } +}; + +/** + * An internal base class for implementing drag operations on top + * of drop class. + */ +export +abstract class DragDropWidgetBase extends DropWidget { + + /** + * Construct a drag and drop base widget. + */ + constructor(options: DragDropWidget.IOptions={}) { + super(options); + this.addClass(DRAG_WIDGET_CLASS); + } + + /** + * Dispose of the resources held by the directory listing. + */ + dispose(): void { + this.drag = null; + this._clickData = null; + super.dispose(); + } + + /** + * Handle the DOM events for the widget. + * + * @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 drag widget's node. It should + * not be called directly by user code. + */ + handleEvent(event: Event): void { + switch (event.type) { + case 'mousedown': + this._evtDragMousedown(event as MouseEvent); + break; + case 'mouseup': + this._evtDragMouseup(event as MouseEvent); + break; + case 'mousemove': + this._evtDragMousemove(event as MouseEvent); + break; + default: + super.handleEvent(event); + break; + } + } + + /** + * Adds mime data representing the drag data to the drag event's MimeData bundle. + */ + protected abstract addMimeData(handle: HTMLElement, mimeData: MimeData): void; + + /** + * Finds the drag target (the node to move) from a drag handle. + * + * Returns null if no valid drag target was found. + * + * The default implementation returns the handle directly. + */ + protected findDragTarget(handle: HTMLElement): HTMLElement | null { + return handle; + } + + /** + * Returns the drag image to use when dragging using the given handle. + * + * The default implementation returns a clone of the drag target. + */ + protected getDragImage(handle: HTMLElement): HTMLElement | null { + let target = this.findDragTarget(handle); + if (target) { + return target.cloneNode(true) as HTMLElement; + } + return null; + } + + /** + * Called when a drag has completed with this widget as a source + */ + protected onDragComplete(action: DropAction) { + this.drag = null; + } + + /** + * Handle `after_attach` messages for the widget. + */ + protected onAfterAttach(msg: Message): void { + let node = this.node; + node.addEventListener('mousedown', this); + super.onAfterAttach(msg); + } + + /** + * Handle `before_detach` messages for the widget. + */ + protected onBeforeDetach(msg: Message): void { + let node = this.node; + node.removeEventListener('click', this); + node.removeEventListener('dblclick', this); + document.removeEventListener('mousemove', this, true); + document.removeEventListener('mouseup', this, true); + super.onBeforeDetach(msg); + } + + /** + * Start a drag event. + * + * Called when dragging and DRAG_THRESHOLD is met. + * + * Should normally only be overriden if you cannot achieve your goal by + * other overrides. + */ + protected startDrag(handle: HTMLElement, clientX: number, clientY: number): void { + // Create the drag image. + let dragImage = this.getDragImage(handle); + + // Set up the drag event. + this.drag = new Drag({ + dragImage: dragImage || undefined, + mimeData: new MimeData(), + supportedActions: this.defaultSupportedActions, + proposedAction: this.defaultProposedAction, + source: this + }); + this.addMimeData(handle, this.drag.mimeData); + + // Start the drag and remove the mousemove listener. + this.drag.start(clientX, clientY).then(this.onDragComplete.bind(this)); + document.removeEventListener('mousemove', this, true); + document.removeEventListener('mouseup', this, true); + } + + /** + * Drag data stored in _startDrag + */ + protected drag: Drag | null = null; + + protected dragHandleClass = DRAG_HANDLE; + + /** + * Check if node, or any of nodes ancestors are a drag handle + * + * If it is a drag handle, it returns the handle, if not returns null. + */ + private _findDragHandle(node: HTMLElement): HTMLElement | null { + let handle: HTMLElement | null = null; + // Traverse up DOM to check if click is on a drag handle + let candidate: HTMLElement | null = node; + while (candidate && candidate !== this.node) { + if (candidate.classList.contains(this.dragHandleClass)) { + handle = candidate; + break; + } + candidate = candidate.parentElement; + } + // Finally, check that handle does not belong to a nested drag widget + if (handle !== null && !belongsToUs( + handle, DRAG_WIDGET_CLASS, this.node)) { + // Handle belongs to a nested drag widget: + handle = null; + } + return handle; + } + + /** + * Handle the `'mousedown'` event for the widget. + */ + private _evtDragMousedown(event: MouseEvent): void { + let target = event.target as HTMLElement; + let handle = this._findDragHandle(target); + if (handle === null) { + return; + } + + // Left mouse press for drag start. + if (event.button === 0) { + this._clickData = { pressX: event.clientX, pressY: event.clientY, + handle: handle }; + document.addEventListener('mouseup', this, true); + document.addEventListener('mousemove', this, true); + event.preventDefault(); + } + } + + + /** + * Handle the `'mouseup'` event for the widget. + */ + private _evtDragMouseup(event: MouseEvent): void { + if (event.button !== 0 || !this.drag) { + document.removeEventListener('mousemove', this, true); + document.removeEventListener('mouseup', this, true); + this.drag = null; + return; + } + event.preventDefault(); + event.stopPropagation(); + } + + /** + * Handle the `'mousemove'` event for the widget. + */ + private _evtDragMousemove(event: MouseEvent): void { + // Bail if we are already dragging. + if (this.drag) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + // Check for a drag initialization. + let data = this._clickData; + if (!data) { + throw new Error('Missing drag data'); + } + let dx = Math.abs(event.clientX - data.pressX); + let dy = Math.abs(event.clientY - data.pressY); + if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) { + return; + } + + this.startDrag(data.handle, event.clientX, event.clientY); + this._clickData = null; + } + + protected defaultSupportedActions: SupportedActions = 'all'; + protected defaultProposedAction: DropAction = 'move'; + + /** + * Data stored on mouse down to determine if drag treshold has + * been overcome, and to initialize drag once it has. + */ + private _clickData: { pressX: number, pressY: number, handle: HTMLElement } | null = null; +} + +/** + * A widget which allows the user to initiate drag operations. + * + * Any descendant element with the drag handle class `'jfs-mod-dragHandle'` + * will serve as a handle that can be used for dragging. If DragWidgets are + * nested, handles will only belong to the closest parent DragWidget. For + * convenience, the functions `makeHandle`, `unmakeHandle` and + * `createDefaultHandle` can be used to indicate which elements should be + * made handles. `createDefaultHandle` will create a new element as a handle + * with a default styling class applied. Optionally, `childrenAreDragHandles` + * can be set to indicate that all direct children are themselve drag handles. + * + * To complete the class, the following functions need to be implemented: + * - addMimeData: Adds mime data to new drag events + * + * The functionallity of the class can be extended by overriding the following + * functions: + * - findDragTarget(): Override if anything other than the direct children + * of the widget's node are to be drag targets. + * - getDragImage: Override to change the drag image (the default is a + * copy of the drag target). + * - onDragComplete(): Callback on drag source when a drag has completed. + */ +export +abstract class DragWidget extends DragDropWidgetBase { + /** + * Construct a drag widget. + */ + constructor(options: DragWidget.IOptions={}) { + // Implementation removes DropWidget options + super(options); + } + + /** + * No-op on DragWidget, as it does not support dropping + */ + protected processDrop(dropTarget: HTMLElement, event: IDragEvent): void { + // Intentionally empty + } + + /** + * Simply returns null for DragWidget, as it does not support dropping + */ + protected findDropTarget(input: HTMLElement, mimeData: MimeData): HTMLElement | null { + return null; + } + +} + + +/** + * A widget which allows the user to rearrange widgets in the widget by + * drag and drop. An internal drag and drop of a widget will cause it + * to be inserted (by `insertWidget`) in the index of the widget it was + * dropped on. + * + * Any descendant element with the drag handle class `'jfs-mod-dragHandle'` + * will serve as a handle that can be used for dragging. If DragWidgets are + * nested, handles will only belong to the closest parent DragWidget. For + * convenience, the functions `makeHandle`, `unmakeHandle` and + * `createDefaultHandle` can be used to indicate which elements should be + * made handles. `createDefaultHandle` will create a new element as a handle + * with a default styling class applied. Optionally, `childrenAreDragHandles` + * can be set to indicate that all direct children are themselve drag handles. + * + * The functionallity of the class can be extended by overriding the following + * functions: + * - addMimeData: Override to add other drag data to the mime bundle. + * This is often a necessary step for allowing dragging to external + * drop targets. + * - processDrop: Override if you need to handle other mime data than the + * default. For allowing drops from external sources, the field + * `acceptDropsFromExternalSource` should be set as well. + * - findDragTarget(): Override if anything other than the direct children + * of the widget's node are to be drag targets. + * - findDropTarget(): Override if anything other than the direct children + * of the widget's node are to be the drop targets. + * - getIndexOfChildNode(): Override to change the key used to represent + * the drag and drop target (default is index of child widget). + * - move(): Override to change how a move is handled. + * - getDragImage: Override to change the drag image (the default is a + * copy of the drag target). + * - onDragComplete(): Callback on drag source when a drag has completed. + * + * To drag and drop other things than all direct children, the following functions + * should be overriden: `findDragTarget`, `findDropTarget` and possibly + * `getIndexOfChildNode` and `move` to allow for custom to/from keys. + * + * For maximum control, `startDrag` and `evtDrop` can be overriden. + */ +export abstract class DragDropWidget extends DragDropWidgetBase { + protected abstract move(mimeData: MimeData, target: HTMLElement): DropAction; + + /** + * Adds mime data represeting the drag data to the drag event's MimeData bundle. + * + * The default implementation adds mime data indicating the index of the direct + * child being dragged (as indicated by findDragTarget). + * + * Override this method if you have data that cannot be communicated well by an + * index, for example if the data should be able to be dropped on an external + * target that only understands direct mime data. + * + * As the method simply adds mime data for a specific key, overriders can call + * this method before/after adding their own mime data to still support default + * dragging behavior. + */ + protected abstract addMimeData(handle: HTMLElement, mimeData: MimeData): void; + + /** + * Processes a drop event. + * + * This function is called after checking: + * - That the `dropTarget` is a valid drop target + * - The value of `event.source` if `acceptDropsFromExternalSource` is false + * + * The default implementation assumes calling `getIndexOfChildNode` with + * `dropTarget` will be valid. It will call `move` with that index as `to`, + * and the index stored in the mime data as `from`. + * + * Override this if you need to handle other mime data than the default. + */ + protected processDrop(dropTarget: HTMLElement, event: IDragEvent): void { + if (!DropWidget.isValidAction(event.supportedActions, 'move') || + event.proposedAction === 'none') { + // The default implementation only handles move action + // OR Accept proposed none action, and perform no-op + event.dropAction = 'none'; + event.preventDefault(); + event.stopPropagation(); + return; + } + if (!this.validateSource(event)) { + // Source indicates external drop, incorrect use in subclass + throw new Error('Invalid source!'); + } + + // We have an acceptable drop, handle: + const action = this.move(event.mimeData, dropTarget); + event.preventDefault(); + event.stopPropagation(); + event.dropAction = action; + } +} + + + +/** + * The namespace for the `DropWidget` class statics. + */ +export +namespace DropWidget { + /** + * An options object for initializing a drag widget widget. + */ + export + interface IOptions extends Widget.IOptions { + /** + * Whether the lsit should accept drops from an external source. + * Defaults to false. + * + * This option only makes sense to set for subclasses that accept drops from + * external sources. + */ + acceptDropsFromExternalSource?: boolean; + + /** + * Which mimetypes are acceptable for drops + */ + acceptedDropMimeTypes?: string[]; + } + + /** + * Validate a drop action against a SupportedActions type + */ + export + function isValidAction(supported: SupportedActions, action: DropAction): boolean { + switch (supported) { + case 'all': + return true; + case 'link-move': + return action === 'move' || action === 'link'; + case 'copy-move': + return action === 'move' || action === 'copy'; + case 'copy-link': + return action === 'link' || action === 'copy'; + default: + return action === supported; + } + } +} + +/** + * The namespace for the `DragWidget` class statics. + */ +export +namespace DragWidget { + /** + * An options object for initializing a drag widget widget. + */ + export + interface IOptions extends Widget.IOptions { + } + + /** + * Mark a widget as a drag handle. + * + * Using this, any child-widget can be a drag handle, as long as mouse events + * are propagated from it to the DragWidget. + */ + export + function makeHandle(handle: Widget) { + handle.addClass(DRAG_HANDLE); + } + + /** + * Unmark a widget as a drag handle + */ + export + function unmakeHandle(handle: Widget) { + handle.removeClass(DRAG_HANDLE); + } + + /** + * Create a default handle widget for dragging (see styling in DragWidget.css). + * + * The handle will need to be styled to ensure a minimum size + */ + export + function createDefaultHandle(): Widget { + let widget = new Widget(); + widget.addClass(DEFAULT_DRAG_HANDLE_CLASS); + makeHandle(widget); + return widget; + } +} + + +/** + * The namespace for the `DragDropWidget` class statics. + */ +export +namespace DragDropWidget { + export + interface IOptions extends DragWidget.IOptions, DropWidget.IOptions { + } +} + + + +export +abstract class FriendlyDragDrop extends DragDropWidget { + private static _counter = 0; + private static _groups: {[key: number]: FriendlyDragDrop[]} = {}; + + static makeGroup() { + const id = this._counter++; + FriendlyDragDrop._groups[id] = []; + return id; + } + + setFriendlyGroup(id: number) { + this._groupId = id; + FriendlyDragDrop._groups[id].push(this); + } + + addToFriendlyGroup(other: FriendlyDragDrop) { + other.setFriendlyGroup(this._groupId); + } + + get friends(): FriendlyDragDrop[] { + if (this._groupId === undefined) { + throw new Error('Uninitialized drag-drop group'); + } + return FriendlyDragDrop._groups[this._groupId]; + } + + private _groupId: number; + + protected validateSource(event: IDragEvent) { + if (this.acceptDropsFromExternalSource) { + return this.friends.indexOf(event.source) !== -1; + } + return super.validateSource(event); + } +} + + +// /** +// * Drag and drop class for contents widgets +// */ +// export class DragDropContents extends FriendlyDragDrop { + +// protected move(mimeData: MimeData, target: HTMLElement): DropAction { +// if (!mimeData.hasData(CONTENTS_MIME)) { +// return 'none'; +// } +// return 'none'; +// } + +// protected addMimeData(handle: HTMLElement, mimeData: MimeData): void { + +// } + + +// /** +// * Handle the `'lm-dragenter'` event for the widget. +// */ +// protected findDropTarget(input: HTMLElement, mimeData: MimeData): HTMLElement | null { +// const target = super.findDropTarget(input, mimeData); +// if (!target) { +// return target; +// } +// const item = this._sortedItems[index]; +// if (item.type !== 'directory' || this.selection[item.path]) { +// return; +// } +// } + +// /** +// * Handle the `'lm-drop'` event for the widget. +// */ +// protected processDrop(dropTarget: HTMLElement, event: IDragEvent): void { +// // Get the path based on the target node. +// const basePath = getPath(dropTarget); + +// // Handle the items. +// const promises: Promise[] = []; +// 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. +// */ +// protected 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; +// 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 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); +// }); +// } +// } \ No newline at end of file diff --git a/js/src/index.tsx b/js/src/index.tsx index d49bcc20..7bd87592 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -76,6 +76,7 @@ export const browser: JupyterFrontEndPlugin = { restorer, router, columns, + settings, }; function refreshWidgets({ resources, options }: {resources: IFSResource[]; options: IFSOptions}) { @@ -93,7 +94,7 @@ export const browser: JupyterFrontEndPlugin = { const id = idFromResource(r); let w = widgetMap[id]; if (!w || w.isDisposed) { - const sidebarProps = { ...sharedSidebarProps, url: r.url }; + const sidebarProps = { ...sharedSidebarProps, url: r.url, settings: settings! }; w = TreeFinderSidebar.sidebarFromResource(r, sidebarProps); widgetMap[id] = w; } else { diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index 7868f505..d7474038 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -41,10 +41,13 @@ import { JupyterClipboard } from "./clipboard"; import { commandIDs, idFromResource } from "./commands"; import { ContentsProxy } from "./contents_proxy"; import { revealPath } from "./contents_utils"; +import { DragDropWidget, TABLE_HEADER_MIME } from "./drag"; import { IFSResource } from "./filesystem"; import { fileTreeIcon } from "./icons"; import { promptRename } from "./utils"; import { Uploader, UploadButton } from "./upload"; +import { MimeData } from "@lumino/coreutils"; +import { DropAction } from "@lumino/dragdrop"; import { ContentsManager } from "@jupyterlab/services"; @@ -80,20 +83,23 @@ export class TreeFinderTracker extends WidgetTracker { private _finders = new Map(); } -export class TreeFinderWidget extends Widget { +export class TreeFinderWidget extends DragDropWidget { constructor({ app, columns, rootPath = "", translator, + settings }: TreeFinderWidget.IOptions) { const { commands, serviceManager: { contents } } = app; const node = document.createElement("tree-finder-panel"); - super({ node }); + const acceptedDropMimeTypes = [TABLE_HEADER_MIME]; + super({ node, acceptedDropMimeTypes }); this.addClass("jp-tree-finder"); this.contentsProxy = new ContentsProxy(contents as ContentsManager, rootPath); + this.settings = settings; this.translator = translator || nullTranslator; this._trans = this.translator.load("jupyterlab"); @@ -106,6 +112,50 @@ export class TreeFinderWidget extends Widget { this._ready.catch(reason => showErrorMessage("Failed to init browser", reason as string)); } + protected move(mimeData: MimeData, target: HTMLElement): DropAction { + const source = mimeData.getData(TABLE_HEADER_MIME) as (keyof ContentsProxy.IJupyterContentRow); + const dest = target.innerText as (keyof ContentsProxy.IJupyterContentRow); + void this._reorderColumns(source, dest) + void this.nodeInit(); + return "move"; + } + + protected addMimeData(handle: HTMLElement, mimeData: MimeData): void { + const columnName = handle.innerText; + mimeData.setData(TABLE_HEADER_MIME, columnName); + } + + protected getDragImage(handle: HTMLElement): HTMLElement | null { + let target = this.findDragTarget(handle); + let img = null; + if (target) { + img = target.cloneNode(true) as HTMLElement; + img.classList.add('jp-thead-drag-image'); + } + return img; + } + + /** + * Reorders the columns according to given inputs and saves to user settings + * If `source` is dragged from left to right, it will be inserted to the right side of `dest` + * Else if `source` is dragged from right to left, it will be inserted to the left side of `dest` + */ + private async _reorderColumns(source: (keyof ContentsProxy.IJupyterContentRow), dest: (keyof ContentsProxy.IJupyterContentRow)) { + const sIndex = this._columns.indexOf(source); + const dIndex = this._columns.indexOf(dest); + + if (sIndex < dIndex) { + this._columns.splice(dIndex + 1, 0, source); + this._columns.splice(sIndex, 1); + } + else if (sIndex > dIndex) { + this._columns.splice(sIndex, 1); + this._columns.splice(dIndex, 0, source); + } + + await this.settings?.set("display_columns", this._columns); + } + draw() { this.model?.requestDraw(); } @@ -145,6 +195,15 @@ export class TreeFinderWidget extends Widget { // Fix focus and tabbing let lastSelectIdx = this.model?.selectedLast ? this.model?.contents.indexOf(this.model.selectedLast) : -1; for (const rowHeader of grid.querySelectorAll("tr > th")) { + const tableHeader = rowHeader.querySelector("span.tf-header-name"); + + if (tableHeader) { + // If tableheader is path, do not make it draggable + if (tableHeader.innerText !== 'path') { + tableHeader.classList.add(this.dragHandleClass); + } + } + const nameElement = rowHeader.querySelector("span.rt-group-name"); // Ensure we can tab to all items nameElement?.setAttribute("tabindex", "0"); @@ -224,6 +283,7 @@ export class TreeFinderWidget extends Widget { this.evtKeydown(event as KeyboardEvent); break; case "dragenter": + this.evtNativeDragOverEnter(event as DragEvent); case "dragover": this.evtNativeDragOverEnter(event as DragEvent); break; @@ -235,6 +295,7 @@ export class TreeFinderWidget extends Widget { this.evtNativeDrop(event as DragEvent); break; default: + super.handleEvent(event); break; } } @@ -406,6 +467,7 @@ export namespace TreeFinderWidget { rootPath: string; translator?: ITranslator; + settings?: ISettingRegistry.ISettings; } } @@ -417,6 +479,7 @@ export class TreeFinderSidebar extends Widget { rootPath = "", caption = "TreeFinder", id = "jupyterlab-tree-finder", + settings }: TreeFinderSidebar.IOptions) { super(); this.id = id; @@ -429,7 +492,7 @@ export class TreeFinderSidebar extends Widget { this.toolbar = new Toolbar(); this.toolbar.addClass("jp-tree-finder-toolbar"); - this.treefinder = new TreeFinderWidget({ app, rootPath, columns }); + this.treefinder = new TreeFinderWidget({ app, rootPath, columns, settings }); this.layout = new PanelLayout(); (this.layout as PanelLayout).addWidget(this.toolbar); @@ -524,6 +587,7 @@ export namespace TreeFinderSidebar { caption?: string; id?: string; translator?: ITranslator; + settings?: ISettingRegistry.ISettings; } export interface ISidebarProps extends IOptions { @@ -534,6 +598,7 @@ export namespace TreeFinderSidebar { router: IRouter; side?: string; + settings?: ISettingRegistry.ISettings; } export function sidebarFromResource(resource: IFSResource, props: TreeFinderSidebar.ISidebarProps): TreeFinderSidebar { @@ -555,13 +620,14 @@ export namespace TreeFinderSidebar { restorer, url, columns, + settings, rootPath = "", caption = "TreeFinder", id = "jupyterlab-tree-finder", side = "left", }: TreeFinderSidebar.ISidebarProps): TreeFinderSidebar { - const widget = new TreeFinderSidebar({ app, rootPath, columns, caption, id, url }); + const widget = new TreeFinderSidebar({ app, rootPath, columns, caption, id, url, settings }); void widget.treefinder.ready.then(() => tracker.add(widget)); restorer.add(widget, widget.id); app.shell.add(widget, side); diff --git a/js/style/treefinder.css b/js/style/treefinder.css index a8c54707..b5408761 100644 --- a/js/style/treefinder.css +++ b/js/style/treefinder.css @@ -88,6 +88,12 @@ tree-finder-panel tree-finder-grid table { font-weight: 500; } +.jp-thead-drag-image { + color: var(--jp-ui-font-color1); + font-size: var(--jp-ui-font-size1); + font-weight: 500; +} + .jp-tree-finder .tf-filter-input { background-color: transparent; color: var(--jp-ui-font-color1); From 662b07de23221441173b0cb90cb01df992a8f142 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Fri, 2 Jun 2023 16:52:53 +0100 Subject: [PATCH 02/49] Fix focus restoration Fixes focus restoration when selected element is filtered out. Previously, the filter input would be made to lose focus. --- js/src/treefinder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index d7474038..824ed1d7 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -194,6 +194,7 @@ export class TreeFinderWidget extends DragDropWidget { // Fix focus and tabbing let lastSelectIdx = this.model?.selectedLast ? this.model?.contents.indexOf(this.model.selectedLast) : -1; + const lostFocus = document.activeElement === document.body; for (const rowHeader of grid.querySelectorAll("tr > th")) { const tableHeader = rowHeader.querySelector("span.tf-header-name"); @@ -208,7 +209,7 @@ export class TreeFinderWidget extends DragDropWidget { // Ensure we can tab to all items nameElement?.setAttribute("tabindex", "0"); // Ensure last selected element retains focus after redraw: - if (nameElement && lastSelectIdx !== -1) { + if (lostFocus && nameElement && lastSelectIdx !== -1) { const meta = grid.getMeta(rowHeader); if (meta && meta.y === lastSelectIdx) { nameElement.focus(); From 261534ac483522e87fb4583b49c49c44c9394f17 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Fri, 2 Jun 2023 17:44:53 +0100 Subject: [PATCH 03/49] Allow using left/right/space to epxand folders Adds a check for whether we are renaming, so that it doesn't interfere :) --- js/src/treefinder.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index 824ed1d7..43e127f6 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -379,6 +379,10 @@ export class TreeFinderWidget extends DragDropWidget { } protected evtKeydown(event: KeyboardEvent): void { + // handle any keys unaffacted by renaming status above this check: + if (this.parent?.node.classList.contains("jfs-mod-renaming")) { + return + } switch (event.key) { case "ArrowDown": case "ArrowUp": @@ -386,6 +390,38 @@ export class TreeFinderWidget extends DragDropWidget { event.preventDefault(); this.selectNeighbour(event.key === "ArrowUp" ? -1 : 1, event.shiftKey); break; + case "ArrowLeft": + if (this.model?.selectedLast) { + event.stopPropagation(); + event.preventDefault(); + let selectedLast = this.model.selectedLast; + this.model.collapse(this.model.contents.indexOf(selectedLast)); + } + break; + case "ArrowRight": + if (this.model?.selectedLast) { + event.stopPropagation(); + event.preventDefault(); + let selectedLast = this.model.selectedLast; + this.model.expand(this.model.contents.indexOf(selectedLast)); + } + break; + case " ": // space key + // Toggle expansion if dir + if (this.model?.selectedLast) { + event.stopPropagation(); + event.preventDefault(); + let selectedLast = this.model.selectedLast; + if (selectedLast.hasChildren) { + let selectedIdx = this.model.contents.indexOf(selectedLast); + if (selectedLast.isExpand) { + this.model.collapse(selectedIdx); + } else { + this.model.expand(selectedIdx); + } + } + } + break; } } From adb4c8a377c54ff0d500750f6e6304065bfb8525 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Mon, 5 Jun 2023 10:46:32 +0100 Subject: [PATCH 04/49] Only conditionally expand/collapse If already expanded, don't try to do it again (gets into weird state?). Also: if left clicked on non-expanded element, navigate out to parent + select first child for right arrow on expanded folder. --- js/src/treefinder.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index 43e127f6..3d444801 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -40,7 +40,7 @@ import { Content, ContentsModel, Format, Path, TreeFinderGridElement, TreeFinder import { JupyterClipboard } from "./clipboard"; import { commandIDs, idFromResource } from "./commands"; import { ContentsProxy } from "./contents_proxy"; -import { revealPath } from "./contents_utils"; +import { getContentParent, revealPath } from "./contents_utils"; import { DragDropWidget, TABLE_HEADER_MIME } from "./drag"; import { IFSResource } from "./filesystem"; import { fileTreeIcon } from "./icons"; @@ -395,7 +395,17 @@ export class TreeFinderWidget extends DragDropWidget { event.stopPropagation(); event.preventDefault(); let selectedLast = this.model.selectedLast; - this.model.collapse(this.model.contents.indexOf(selectedLast)); + if (selectedLast.isExpand) { + this.model.collapse(this.model.contents.indexOf(selectedLast)); + } else if (!event.shiftKey) { + // navigate the selection to the next up, if no selection modifiers + void getContentParent(selectedLast, this.model.root).then(parent => { + if (parent != this.model?.root) { + this.model?.selectionModel.select(parent); + return TreeFinderSidebar.scrollIntoView(this, parent.pathstr); + } + }); + } } break; case "ArrowRight": @@ -403,7 +413,17 @@ export class TreeFinderWidget extends DragDropWidget { event.stopPropagation(); event.preventDefault(); let selectedLast = this.model.selectedLast; - this.model.expand(this.model.contents.indexOf(selectedLast)); + if (!selectedLast.isExpand) { + this.model.expand(this.model.contents.indexOf(selectedLast)); + } else if (!event.shiftKey && selectedLast.hasChildren) { + // navigate the selection to the first child, if no selection modifiers + void selectedLast.getChildren().then(children => { + if (children && children.length > 0) { + this.model?.selectionModel.select(children[0]) + return TreeFinderSidebar.scrollIntoView(this, children[0].pathstr); + } + }); + } } break; case " ": // space key From 0220c1ce0b8489ed8c408c2d045962908746e8e7 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Mon, 5 Jun 2023 10:50:43 +0100 Subject: [PATCH 05/49] Tweak logic for range selection --- js/src/treefinder.ts | 46 +++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index 3d444801..bb6440da 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -395,16 +395,19 @@ export class TreeFinderWidget extends DragDropWidget { event.stopPropagation(); event.preventDefault(); let selectedLast = this.model.selectedLast; - if (selectedLast.isExpand) { - this.model.collapse(this.model.contents.indexOf(selectedLast)); - } else if (!event.shiftKey) { - // navigate the selection to the next up, if no selection modifiers - void getContentParent(selectedLast, this.model.root).then(parent => { - if (parent != this.model?.root) { - this.model?.selectionModel.select(parent); - return TreeFinderSidebar.scrollIntoView(this, parent.pathstr); - } - }); + // don't allow expansion or up/down nav if in select range mode: + if (!event.shiftKey) { + if (selectedLast.isExpand) { + this.model.collapse(this.model.contents.indexOf(selectedLast)); + } else { + // navigate the selection to the next up (exluding to root) + void getContentParent(selectedLast, this.model.root).then(parent => { + if (parent != this.model?.root) { + this.model?.selectionModel.select(parent); + return TreeFinderSidebar.scrollIntoView(this, parent.pathstr); + } + }); + } } } break; @@ -413,16 +416,19 @@ export class TreeFinderWidget extends DragDropWidget { event.stopPropagation(); event.preventDefault(); let selectedLast = this.model.selectedLast; - if (!selectedLast.isExpand) { - this.model.expand(this.model.contents.indexOf(selectedLast)); - } else if (!event.shiftKey && selectedLast.hasChildren) { - // navigate the selection to the first child, if no selection modifiers - void selectedLast.getChildren().then(children => { - if (children && children.length > 0) { - this.model?.selectionModel.select(children[0]) - return TreeFinderSidebar.scrollIntoView(this, children[0].pathstr); - } - }); + // don't allow expansion or up/down nav if in select range mode: + if (!event.shiftKey) { + if (!selectedLast.isExpand) { + this.model.expand(this.model.contents.indexOf(selectedLast)); + } else if (selectedLast.hasChildren) { + // navigate the selection to the first child + void selectedLast.getChildren().then(children => { + if (children && children.length > 0) { + this.model?.selectionModel.select(children[0]) + return TreeFinderSidebar.scrollIntoView(this, children[0].pathstr); + } + }); + } } } break; From 17ba80e5ddd15c371deb158445b00f22f075dd94 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 8 Jun 2023 18:03:58 +0100 Subject: [PATCH 06/49] Fix rename bug + refresh Fixes an issue where renaming a folder, would leave an entry that when collapsed / expanded changed to a folder named "/". the issue was that Path.toarray expanded to a blank final element because of trailing slash. Also made sure that containing folder is refreshed after rename in order to trigger a resort. There might be a more efficient way to only ask for a resort without triggering a full request to the server. --- js/src/contents_proxy.ts | 2 +- js/src/treefinder.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/js/src/contents_proxy.ts b/js/src/contents_proxy.ts index 7eece66b..ad52e2ef 100644 --- a/js/src/contents_proxy.ts +++ b/js/src/contents_proxy.ts @@ -68,7 +68,7 @@ export namespace ContentsProxy { export function toJupyterContentRow(row: Contents.IModel, contentsManager: ContentsManager, drive?: string): IJupyterContentRow { const { path, type, ...rest } = row; - const pathWithDrive = toFullPath(path, drive); + const pathWithDrive = toFullPath(path, drive).replace(/\/$/, ""); const kind = type === "directory" ? "dir" : type; return { diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index bb6440da..9cfa1e76 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -40,7 +40,7 @@ import { Content, ContentsModel, Format, Path, TreeFinderGridElement, TreeFinder import { JupyterClipboard } from "./clipboard"; import { commandIDs, idFromResource } from "./commands"; import { ContentsProxy } from "./contents_proxy"; -import { getContentParent, revealPath } from "./contents_utils"; +import { getContentParent, getRefreshTargets, revealPath } from "./contents_utils"; import { DragDropWidget, TABLE_HEADER_MIME } from "./drag"; import { IFSResource } from "./filesystem"; import { fileTreeIcon } from "./icons"; @@ -110,6 +110,20 @@ export class TreeFinderWidget extends DragDropWidget { // CAREFUL: tree-finder currently REQUIRES the node to be added to the DOM before init can be called! this._ready = this.nodeInit(); this._ready.catch(reason => showErrorMessage("Failed to init browser", reason as string)); + this._ready.then(() => { + // TODO: Model state of TreeFinderWidget should be updated by renamerSub process. + // Currently we hard-code the refresh here, but should be moved upstream! + const contentsModel = this.model!; + contentsModel.renamerSub.subscribe(async ({ name, target }) => { + const destination = target.row; + let toRefresh = getRefreshTargets( + [destination], + contentsModel.root, + true + ); + contentsModel.refreshSub.next(toRefresh); + }); + }) } protected move(mimeData: MimeData, target: HTMLElement): DropAction { From 08c11d701d84edd9ac01dd53483fb855324f6db5 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 8 Jun 2023 18:21:37 +0100 Subject: [PATCH 07/49] Only sort, avoid network request --- js/src/treefinder.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index 9cfa1e76..ce640865 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -40,7 +40,7 @@ import { Content, ContentsModel, Format, Path, TreeFinderGridElement, TreeFinder import { JupyterClipboard } from "./clipboard"; import { commandIDs, idFromResource } from "./commands"; import { ContentsProxy } from "./contents_proxy"; -import { getContentParent, getRefreshTargets, revealPath } from "./contents_utils"; +import { getContentParent, revealPath } from "./contents_utils"; import { DragDropWidget, TABLE_HEADER_MIME } from "./drag"; import { IFSResource } from "./filesystem"; import { fileTreeIcon } from "./icons"; @@ -115,13 +115,7 @@ export class TreeFinderWidget extends DragDropWidget { // Currently we hard-code the refresh here, but should be moved upstream! const contentsModel = this.model!; contentsModel.renamerSub.subscribe(async ({ name, target }) => { - const destination = target.row; - let toRefresh = getRefreshTargets( - [destination], - contentsModel.root, - true - ); - contentsModel.refreshSub.next(toRefresh); + contentsModel.sort(); }); }) } From ce6efdd5aac7a5e6ec7bb6ce8f44cdd83a9a43ff Mon Sep 17 00:00:00 2001 From: Dave Malvin Limanda <47667667+davemalvin@users.noreply.github.com> Date: Wed, 12 Apr 2023 15:25:22 +0100 Subject: [PATCH 08/49] Add preferred_dir in settings --- js/schema/plugin.json | 4 ++++ js/src/contents_utils.ts | 16 ++++++++++++++++ js/src/filesystem.ts | 6 +++++- js/src/treefinder.ts | 16 +++++++++++++++- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/js/schema/plugin.json b/js/schema/plugin.json index 545a08c6..36904836 100644 --- a/js/schema/plugin.json +++ b/js/schema/plugin.json @@ -49,6 +49,10 @@ "description": "A url pointing to an fs resource, as per the PyFilesystem fsurl specification", "type": "string" }, + "preferred_dir": { + "description": "Directory to be first opened (e.g., myDir/mySubdir)", + "type": "string" + }, "auth": { "description": "Given any template {{VARS}} in the url, 'ask' (default) to open a dialog box asking for credentials, or `env` to pick up credentials from the server's environment variables", "type": "string", diff --git a/js/src/contents_utils.ts b/js/src/contents_utils.ts index 0fe22c26..61db89b5 100644 --- a/js/src/contents_utils.ts +++ b/js/src/contents_utils.ts @@ -75,6 +75,22 @@ export async function revealAndSelectPath(contents: Conte return node; } + +/** + * Recursively opens directories in a contents model + * + * @param model The contents model where directories are to be opened + * @param path Array of directory names to be opened in order + */ +export async function openDirRecursive(model: ContentsModel, path: string[]) { + for await (const node of walkPath(path, model.root)) { + if (node.pathstr !== model.root.pathstr) { + model.openDir(node.row); + } + } +} + + /** * Get the parent contents row for the given contents row. * diff --git a/js/src/filesystem.ts b/js/src/filesystem.ts index 9ddabe03..deff7c0e 100644 --- a/js/src/filesystem.ts +++ b/js/src/filesystem.ts @@ -40,6 +40,11 @@ export interface IFSResource { */ auth: "ask" | "env" | false; + /** + * Directory to be first opened + */ + preferred_dir?: string; + /** * The jupyterlab drive name associated with this resource. This is defined * on resource initialization @@ -160,7 +165,6 @@ export class FSComm extends FSCommBase { throw new ServerConnection.ResponseError(response, data); }); } - return response.json(); }); } diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index ce640865..814528f5 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -40,7 +40,7 @@ import { Content, ContentsModel, Format, Path, TreeFinderGridElement, TreeFinder import { JupyterClipboard } from "./clipboard"; import { commandIDs, idFromResource } from "./commands"; import { ContentsProxy } from "./contents_proxy"; -import { getContentParent, revealPath } from "./contents_utils"; +import { getContentChild, getContentParent, revealPath, openDirRecursive } from "./contents_utils"; import { DragDropWidget, TABLE_HEADER_MIME } from "./drag"; import { IFSResource } from "./filesystem"; import { fileTreeIcon } from "./icons"; @@ -654,6 +654,7 @@ export namespace TreeFinderSidebar { columns: Array; url: string; + preferredDir?: string; rootPath?: string; caption?: string; id?: string; @@ -678,6 +679,7 @@ export namespace TreeFinderSidebar { rootPath: resource.drive, caption: `${resource.name}\nFile Tree`, id: idFromResource(resource), + preferredDir: resource.preferred_dir, url: resource.url, }); } @@ -692,6 +694,7 @@ export namespace TreeFinderSidebar { url, columns, settings, + preferredDir, rootPath = "", caption = "TreeFinder", @@ -733,6 +736,17 @@ export namespace TreeFinderSidebar { widget.toolbar.addItem("upload", uploader_button); widget.toolbar.addItem("refresh", refresh_button); + if (preferredDir) { + void widget.treefinder.ready.then(async () => { + var path = preferredDir.split("/"); + if (preferredDir.startsWith("/")) { + path = path.slice(1); + }; + path.unshift(rootPath); + openDirRecursive(widget.treefinder.model!, path); + }) + } + // // remove context highlight on context menu exit // document.ondblclick = () => { // app.commands.execute((widget.commandIDs.set_context + ":" + widget.id), { path: "" }); From 0bad118551ec734698e8d273c461d5f7293fae2a Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Tue, 6 Jun 2023 12:41:47 +0100 Subject: [PATCH 09/49] Disable the download command for folders --- js/src/commands.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/src/commands.ts b/js/src/commands.ts index 05d312ba..9acd0f84 100644 --- a/js/src/commands.ts +++ b/js/src/commands.ts @@ -218,7 +218,9 @@ export function createCommands( }, icon: downloadIcon, label: "Download", - isEnabled: () => !!(tracker.currentWidget?.treefinder.model?.selection), + isEnabled: () => !!( + tracker.currentWidget?.treefinder.model?.selection?.some(s => !s.hasChildren) + ), }), app.commands.addCommand(commandIDs.create_folder, { execute: async args => { From b7132b754cdead1ba6546dffa8f90c0cd7650aab Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Tue, 25 Jul 2023 09:24:18 -0400 Subject: [PATCH 10/49] Fix special character in credentials PyFilesystem relies on users urlencoding the credentials in order to support special characters. Also adds tests and cleans up implementation --- jupyterfs/auth.py | 22 ++++---------- jupyterfs/tests/test_auth.py | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 jupyterfs/tests/test_auth.py diff --git a/jupyterfs/auth.py b/jupyterfs/auth.py index ce3ac17d..b76b780b 100644 --- a/jupyterfs/auth.py +++ b/jupyterfs/auth.py @@ -7,9 +7,9 @@ # import os from string import Template +import urllib.parse __all__ = [ - "BraceTemplate", "DoubleBraceTemplate", "substituteAsk", "substituteEnv", @@ -22,20 +22,6 @@ def tokens(self): return [m[0] for m in self.pattern.findall(self.template)] -class BraceTemplate(_BaseTemplate): - """Template subclass that will replace any '{VAR}'""" - - delimiter = "" - pattern = r""" - (?: - [^{]{(?P\S+?)}[^}] | # match anything in single braces - (?Pa^) | # match nothing - (?Pa^) | # match nothing - (?Pa^) # match nothing - ) - """ - - class DoubleBraceTemplate(_BaseTemplate): """Template subclass that will replace any '{{VAR}}'""" @@ -52,7 +38,9 @@ class DoubleBraceTemplate(_BaseTemplate): def substituteAsk(resource): if "tokenDict" in resource: - url = DoubleBraceTemplate(resource["url"]).substitute(resource.pop("tokenDict")) + url = DoubleBraceTemplate(resource["url"]).safe_substitute( + { k: urllib.parse.quote(v) for k, v in resource.pop("tokenDict").items() } + ) else: url = resource["url"] @@ -61,7 +49,7 @@ def substituteAsk(resource): def substituteEnv(resource): - url = DoubleBraceTemplate(resource["url"]).substitute(os.environ) + url = DoubleBraceTemplate(resource["url"]).safe_substitute(os.environ) # return the substituted string and the names of any missing vars return url, DoubleBraceTemplate(url).tokens() diff --git a/jupyterfs/tests/test_auth.py b/jupyterfs/tests/test_auth.py new file mode 100644 index 00000000..c95766f5 --- /dev/null +++ b/jupyterfs/tests/test_auth.py @@ -0,0 +1,58 @@ +import os +import unittest.mock + +from fs.opener.parse import parse_fs_url +from jupyterfs.auth import substituteAsk, substituteEnv, substituteNone +import pytest + + +# we use `prefix_username` as `username` is often present in os.environ + +urls = [ + "foo://bar", + "foo://username@bar", + "foo://username:pword@bar", + "foo://{{prefix_username}}:pword@bar", + "foo://{{prefix_username}}:{{pword}}@bar", +] + +token_dicts = [ + {}, + {'prefix_username': 'username'}, + {'pword': 'pword'}, + {'prefix_username': 'username', 'pword': 'pword'}, + {'prefix_username': 'user:na@me', 'pword': 'pwo@r:d'}, +] + + +def _url_tokens_pair(): + for url in urls: + for token_dict in token_dicts: + yield url, token_dict + + +@pytest.fixture(params=_url_tokens_pair()) +def any_url_token_ask_resource(request): + url, token_dict = request.param + return dict(url=url, tokenDict = token_dict) + +@pytest.fixture(params=_url_tokens_pair()) +def any_url_token_env_resource(request): + url, token_dict = request.param + with unittest.mock.patch.dict(os.environ, token_dict): + yield dict(url=url) + + +def test_ensure_ask_validates(any_url_token_ask_resource): + url, missing = substituteAsk(any_url_token_ask_resource) + if missing: + return pytest.xfail(f'tokens are not sufficient, missing: {missing}') + # simply ensure it doesn't throw: + parse_fs_url(url) + +def test_ensure_env_validates(any_url_token_env_resource): + url, missing = substituteEnv(any_url_token_env_resource) + if missing: + return pytest.xfail(f'tokens are not sufficient, missing: {missing}') + # simply ensure it doesn't throw: + parse_fs_url(url) From 1d5930aec473a2b0842c11481b10736ef40e0e57 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Tue, 25 Jul 2023 17:33:53 +0100 Subject: [PATCH 11/49] Elide long URLs Before this, very long URLs would overflow in an unflattering way. This causes the overflow to cut off with an ellipsis, but the value to still be acsssible (via tooltip for mouse users, and screenreaders don't really care about text-overflow and just reads the entire value. --- js/src/auth.tsx | 7 ++++++- js/style/auth.css | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/js/src/auth.tsx b/js/src/auth.tsx index 0ed05fa2..58a3fc75 100644 --- a/js/src/auth.tsx +++ b/js/src/auth.tsx @@ -20,6 +20,7 @@ import { TextField, InputAdornment, IconButton, + Tooltip, } from "@material-ui/core"; import * as React from "react"; @@ -153,7 +154,11 @@ export class AskDialog< > {summary} - {!reason && {resource.url}} + {!reason && + + {resource.url} + + } {inputs} diff --git a/js/style/auth.css b/js/style/auth.css index 33f52b14..5dabb986 100644 --- a/js/style/auth.css +++ b/js/style/auth.css @@ -41,3 +41,11 @@ .jfs-ask-panel-summary > div { flex-direction: column; } + +/* Elide the typography fields (mainly URL) */ +.jfs-ask-panel.MuiExpansionPanel-root p { + white-space: nowrap; + max-width: 520px; + text-overflow: ellipsis; + overflow: hidden; +} From 9d8797808b5156d06d10eec837c5f4cb249937c1 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 27 Jul 2023 10:49:59 +0100 Subject: [PATCH 12/49] Add "new file" command and context menu entry Before this, you could only create new files via kernels or copy-pasting existing files... --- js/src/commands.ts | 52 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/js/src/commands.ts b/js/src/commands.ts index 9acd0f84..bf2bb5cf 100644 --- a/js/src/commands.ts +++ b/js/src/commands.ts @@ -18,6 +18,7 @@ import { filterListIcon, pasteIcon, refreshIcon, + fileIcon, newFolderIcon, } from "@jupyterlab/ui-components"; import { DisposableSet, IDisposable } from "@lumino/disposable"; @@ -45,7 +46,7 @@ export const commandNames = [ "rename", "download", "create_folder", - // "create_file", + "create_file", // "navigate", "copyFullPath", "copyRelativePath", @@ -256,6 +257,40 @@ export function createCommands( label: "New Folder", isEnabled: () => !!tracker.currentWidget, }), + app.commands.addCommand(commandIDs.create_file, { + execute: async args => { + const widget = tracker.currentWidget!; + const model = widget.treefinder.model!; + let target = model.selectedLast ?? model.root; + if (!target.hasChildren) { + target = await getContentParent(target, model.root); + } + const path = Path.fromarray(target.row.path); + let row: ContentsProxy.IJupyterContentRow; + try { + row = await widget.treefinder.contentsProxy.newUntitled({ + type: "file", + path, + }); + } catch (e) { + showErrorMessage("Could not create file", e); + return; + } + target.invalidate(); + const content = await revealAndSelectPath(model, row.path); + // Is this really needed? + model.refreshSub.next(getRefreshTargets([target.row], model.root)); + // Scroll into view if not visible + await TreeFinderSidebar.scrollIntoView(widget.treefinder, content.pathstr); + const newContent = await TreeFinderSidebar.doRename(widget, content); + model.renamerSub.next( { name: newContent.name, target: content } ); + // TODO: Model state of TreeFinderWidget should be updated by renamerSub process. + content.row = newContent; + }, + icon: fileIcon, + label: "New File", + isEnabled: () => !!tracker.currentWidget, + }), app.commands.addCommand(commandIDs.refresh, { execute: args => { if (args["selection"]) { @@ -361,6 +396,21 @@ export function createCommands( selector, rank: contextMenuRank++, }), + app.contextMenu.addItem({ + command: commandIDs.create_file, + selector, + rank: contextMenuRank++, + }), + app.contextMenu.addItem({ + command: commandIDs.create_folder, + selector, + rank: contextMenuRank++, + }), + app.contextMenu.addItem({ + type: "separator", + selector, + rank: contextMenuRank++, + }), app.contextMenu.addItem({ type: "submenu", submenu, From 21712f580a31d2e6765b390539e177d8cc10c6fd Mon Sep 17 00:00:00 2001 From: Jacob Frazer <64307592+jacob-frazer@users.noreply.github.com> Date: Fri, 28 Jul 2023 10:44:14 +0100 Subject: [PATCH 13/49] Changing fsmanager to use scandir --- jupyterfs/fsmanager.py | 53 +++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/jupyterfs/fsmanager.py b/jupyterfs/fsmanager.py index 3063b992..31bfa2eb 100644 --- a/jupyterfs/fsmanager.py +++ b/jupyterfs/fsmanager.py @@ -231,11 +231,12 @@ def exists(self, path): """ return self._pyfilesystem_instance.exists(path) - def _base_model(self, path): + def _base_model(self, path, info=None): """Build the common base of a contents model""" - info = self._pyfilesystem_instance.getinfo( - path, namespaces=["details", "access"] - ) + if not info: + info = self._pyfilesystem_instance.getinfo( + path, namespaces=["details", "access"] + ) try: # size of file @@ -256,7 +257,7 @@ def _base_model(self, path): # Create the base model. model = {} - model["name"] = path.rsplit("/", 1)[-1] + model["name"] = info.name model["path"] = path model["last_modified"] = last_modified model["created"] = created @@ -287,8 +288,9 @@ def _base_model(self, path): except AttributeError: model["writable"] = False return model + - def _dir_model(self, path, content=True): + def _dir_model(self, path, content=True, info=None): """Build a model for a directory if content is requested, will include a listing of the directory """ @@ -300,26 +302,22 @@ def _dir_model(self, path, content=True): self.log.debug("Refusing to serve hidden directory %r, via 404 Error", path) raise web.HTTPError(404, four_o_four) - model = self._base_model(path) + model = self._base_model(path, info) model["type"] = "directory" model["size"] = None if content: model["content"] = contents = [] - for name in self._pyfilesystem_instance.listdir(path): - os_path = fs.path.join(path, name) + + for file_info in self._pyfilesystem_instance.scandir(path, namespaces=("basic", "access", "link", "details")): try: - if ( - not self._pyfilesystem_instance.islink(os_path) - and not self._pyfilesystem_instance.isfile(os_path) - and not self._pyfilesystem_instance.isdir(os_path) - ): - self.log.debug("%s not a regular file", os_path) + if (not file_info.is_file and not file_info.is_dir and (file_info.has_namespace("link") and not file_info.is_link)): + self.log.debug("%s not a regular file", path) continue - if self.should_list(name): - if self.allow_hidden or not self._is_path_hidden(name): + if self.should_list(file_info.name): + if self.allow_hidden or not self._is_path_hidden(file_info.name): contents.append( - self.get(path="%s/%s" % (path, name), content=False) + self.get(path="%s/%s" % (path, file_info.name), content=False, info=file_info) ) except PermissionDenied: pass # Don't provide clues about protected files @@ -328,7 +326,8 @@ def _dir_model(self, path, content=True): # us from listing other entries pass except Exception as e: - self.log.warning("Error stat-ing %s: %s", os_path, e) + self.log.warning("Error stat-ing %s: %s", file_info.make_path(path), e) + model["format"] = "json" return model @@ -366,7 +365,7 @@ def _read_notebook(self, path, as_version=4): nb, format = self._read_file(path, "text") return nbformat.reads(nb, as_version=as_version) - def _file_model(self, path, content=True, format=None): + def _file_model(self, path, content=True, format=None, info=None): """Build a model for a file if content is requested, include the file contents. format: @@ -374,7 +373,7 @@ def _file_model(self, path, content=True, format=None): If 'base64', the raw bytes contents will be encoded as base64. If not specified, try to decode as UTF-8, and fall back to base64 """ - model = self._base_model(path) + model = self._base_model(path, info) model["type"] = "file" model["mimetype"] = mimetypes.guess_type(path)[0] @@ -394,12 +393,12 @@ def _file_model(self, path, content=True, format=None): return model - def _notebook_model(self, path, content=True): + def _notebook_model(self, path, content=True, info=None): """Build a notebook model if content is requested, the notebook content will be populated as a JSON structure (not double-serialized) """ - model = self._base_model(path) + model = self._base_model(path, info) model["type"] = "notebook" if content: nb = self._read_notebook(path, as_version=4) @@ -409,7 +408,7 @@ def _notebook_model(self, path, content=True): self.validate_notebook_model(model) return model - def get(self, path, content=True, type=None, format=None): + def get(self, path, content=True, type=None, format=None, info=None): """Takes a path for an entity and returns its model Args: path (str): the API path that describes the relative path for the target @@ -429,15 +428,15 @@ def get(self, path, content=True, type=None, format=None): raise web.HTTPError( 400, "%s is a directory, not a %s" % (path, type), reason="bad type" ) - model = self._dir_model(path, content=content) + model = self._dir_model(path, content=content, info=info) elif type == "notebook" or (type is None and path.endswith(".ipynb")): - model = self._notebook_model(path, content=content) + model = self._notebook_model(path, content=content, info=info) else: if type == "directory": raise web.HTTPError( 400, "%s is not a directory" % path, reason="bad type" ) - model = self._file_model(path, content=content, format=format) + model = self._file_model(path, content=content, format=format, info=info) return model def _save_directory(self, path, model): From 500f15fea316abd07df9981c6ad1f842609a3fe1 Mon Sep 17 00:00:00 2001 From: Jacob Frazer <64307592+jacob-frazer@users.noreply.github.com> Date: Fri, 28 Jul 2023 16:09:31 +0100 Subject: [PATCH 14/49] Improve variable naming and docstrings --- jupyterfs/fsmanager.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/jupyterfs/fsmanager.py b/jupyterfs/fsmanager.py index 31bfa2eb..b6edc713 100644 --- a/jupyterfs/fsmanager.py +++ b/jupyterfs/fsmanager.py @@ -232,7 +232,12 @@ def exists(self, path): return self._pyfilesystem_instance.exists(path) def _base_model(self, path, info=None): - """Build the common base of a contents model""" + """ + Build the common base of a contents model + + if `info` passed, then that FS.Info object is used for values instead of getinfo on provided path + this saves a getinfo call and should speed up processing when used with scandir (which returns Info objs) + """ if not info: info = self._pyfilesystem_instance.getinfo( path, namespaces=["details", "access"] @@ -293,6 +298,7 @@ def _base_model(self, path, info=None): def _dir_model(self, path, content=True, info=None): """Build a model for a directory if content is requested, will include a listing of the directory + if `info` passed, given to _base_model so it doesn't need to make getinfo request on `path` """ four_o_four = "directory does not exist: %r" % path @@ -308,16 +314,16 @@ def _dir_model(self, path, content=True, info=None): if content: model["content"] = contents = [] - for file_info in self._pyfilesystem_instance.scandir(path, namespaces=("basic", "access", "link", "details")): + for dir_entry in self._pyfilesystem_instance.scandir(path, namespaces=("basic", "access", "link", "details")): try: - if (not file_info.is_file and not file_info.is_dir and (file_info.has_namespace("link") and not file_info.is_link)): + if (not dir_entry.is_file and not dir_entry.is_dir and (dir_entry.has_namespace("link") and not dir_entry.is_link)): self.log.debug("%s not a regular file", path) continue - if self.should_list(file_info.name): - if self.allow_hidden or not self._is_path_hidden(file_info.name): + if self.should_list(dir_entry.name): + if self.allow_hidden or not self._is_path_hidden(dir_entry.name): contents.append( - self.get(path="%s/%s" % (path, file_info.name), content=False, info=file_info) + self.get(path="%s/%s" % (path, dir_entry.name), content=False, info=dir_entry) ) except PermissionDenied: pass # Don't provide clues about protected files @@ -326,7 +332,7 @@ def _dir_model(self, path, content=True, info=None): # us from listing other entries pass except Exception as e: - self.log.warning("Error stat-ing %s: %s", file_info.make_path(path), e) + self.log.warning("Error stat-ing %s: %s", dir_entry.make_path(path), e) model["format"] = "json" return model @@ -372,6 +378,8 @@ def _file_model(self, path, content=True, format=None, info=None): If 'text', the contents will be decoded as UTF-8. If 'base64', the raw bytes contents will be encoded as base64. If not specified, try to decode as UTF-8, and fall back to base64 + + if `info` passed, given to _base_model so it doesn't need to make getinfo request on `path` """ model = self._base_model(path, info) model["type"] = "file" @@ -397,6 +405,7 @@ def _notebook_model(self, path, content=True, info=None): """Build a notebook model if content is requested, the notebook content will be populated as a JSON structure (not double-serialized) + if `info` passed, given to _base_model so it doesn't need to make getinfo request on `path` """ model = self._base_model(path, info) model["type"] = "notebook" @@ -415,6 +424,8 @@ def get(self, path, content=True, type=None, format=None, info=None): content (bool): Whether to include the contents in the reply type (str): The requested type - 'file', 'notebook', or 'directory'. Will raise HTTPError 400 if the content doesn't match. format (str): The requested format for file contents. 'text' or 'base64'. Ignored if this returns a notebook or directory model. + info (fs Info object): FS Info directly rather than path, if present no need to call getinfo on path -- combined with scandir should + improve efficiency saving some amount of network calls as needed info is cached in object Returns model (dict): the contents model. If content=True, returns the contents of the file or directory as well. """ From 26b873ead12dda26a4352b68be85379674f48702 Mon Sep 17 00:00:00 2001 From: Jacob Frazer <64307592+jacob-frazer@users.noreply.github.com> Date: Fri, 28 Jul 2023 17:06:12 +0100 Subject: [PATCH 15/49] Remove always falsey check --- jupyterfs/fsmanager.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/jupyterfs/fsmanager.py b/jupyterfs/fsmanager.py index b6edc713..953fbc46 100644 --- a/jupyterfs/fsmanager.py +++ b/jupyterfs/fsmanager.py @@ -314,12 +314,8 @@ def _dir_model(self, path, content=True, info=None): if content: model["content"] = contents = [] - for dir_entry in self._pyfilesystem_instance.scandir(path, namespaces=("basic", "access", "link", "details")): + for dir_entry in self._pyfilesystem_instance.scandir(path, namespaces=("basic", "access", "details")): try: - if (not dir_entry.is_file and not dir_entry.is_dir and (dir_entry.has_namespace("link") and not dir_entry.is_link)): - self.log.debug("%s not a regular file", path) - continue - if self.should_list(dir_entry.name): if self.allow_hidden or not self._is_path_hidden(dir_entry.name): contents.append( From 1b4ffad3f73e9c9ef3ba6f6c06d4ad922795a612 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Tue, 1 Aug 2023 11:40:30 +0100 Subject: [PATCH 16/49] Fix JS lints --- js/src/auth.tsx | 2 +- js/src/commands.ts | 3 +- js/src/contents_utils.ts | 2 +- js/src/drag.ts | 226 +++++++++++++++++++-------------------- js/src/filesystem.ts | 2 +- js/src/treefinder.ts | 55 +++++----- 6 files changed, 143 insertions(+), 147 deletions(-) diff --git a/js/src/auth.tsx b/js/src/auth.tsx index 58a3fc75..ebb9e082 100644 --- a/js/src/auth.tsx +++ b/js/src/auth.tsx @@ -154,7 +154,7 @@ export class AskDialog< > {summary} - {!reason && + {!reason && {resource.url} diff --git a/js/src/commands.ts b/js/src/commands.ts index bf2bb5cf..8632bdac 100644 --- a/js/src/commands.ts +++ b/js/src/commands.ts @@ -273,7 +273,7 @@ export function createCommands( path, }); } catch (e) { - showErrorMessage("Could not create file", e); + void showErrorMessage("Could not create file", e); return; } target.invalidate(); @@ -322,7 +322,6 @@ export function createCommands( label: "Copy Relative Path", isEnabled: () => !!tracker.currentWidget, }), - app.commands.addCommand(commandIDs.toggleColumnPath, { execute: args => { /* no-op */ }, label: "path", diff --git a/js/src/contents_utils.ts b/js/src/contents_utils.ts index 61db89b5..1506095b 100644 --- a/js/src/contents_utils.ts +++ b/js/src/contents_utils.ts @@ -85,7 +85,7 @@ export async function revealAndSelectPath(contents: Conte export async function openDirRecursive(model: ContentsModel, path: string[]) { for await (const node of walkPath(path, model.root)) { if (node.pathstr !== model.root.pathstr) { - model.openDir(node.row); + await model.openDir(node.row); } } } diff --git a/js/src/drag.ts b/js/src/drag.ts index 39544ba7..6fb7b9e7 100644 --- a/js/src/drag.ts +++ b/js/src/drag.ts @@ -52,49 +52,49 @@ The core team that coordinates development on GitHub can be found here: https://github.com/jupyter/. */ -'use strict'; +"use strict"; import { - Widget -} from '@lumino/widgets'; + Widget, +} from "@lumino/widgets"; import type { - Message -} from '@lumino/messaging'; + Message, +} from "@lumino/messaging"; import { - MimeData -} from '@lumino/coreutils'; + MimeData, +} from "@lumino/coreutils"; import { - Drag, IDragEvent, DropAction, SupportedActions -} from '@lumino/dragdrop'; + Drag, IDragEvent, DropAction, SupportedActions, +} from "@lumino/dragdrop"; /** * The class name added to the DropWidget */ -const DROP_WIDGET_CLASS = 'jfs-DropWidget'; +const DROP_WIDGET_CLASS = "jfs-DropWidget"; /** * The class name added to the DragWidget */ -const DRAG_WIDGET_CLASS = 'jfs-DragWidget'; +const DRAG_WIDGET_CLASS = "jfs-DragWidget"; /** * The class name added to something which can be used to drag a box */ -const DRAG_HANDLE = 'jfs-mod-dragHandle'; +const DRAG_HANDLE = "jfs-mod-dragHandle"; /** * The class name of the default drag handle */ -const DEFAULT_DRAG_HANDLE_CLASS = 'jfs-DragWidget-dragHandle'; +const DEFAULT_DRAG_HANDLE_CLASS = "jfs-DragWidget-dragHandle"; /** * The class name added to a drop target. */ -const DROP_TARGET_CLASS = 'jfs-mod-dropTarget'; +const DROP_TARGET_CLASS = "jfs-mod-dropTarget"; /** * The threshold in pixels to start a drag event. @@ -104,7 +104,7 @@ const DRAG_THRESHOLD = 5; /** * The mime type for a table header drag object. */ -export const TABLE_HEADER_MIME = 'application/x-jupyterfs-thead'; +export const TABLE_HEADER_MIME = "application/x-jupyterfs-thead"; /** @@ -113,7 +113,7 @@ export const TABLE_HEADER_MIME = 'application/x-jupyterfs-thead'; */ export function belongsToUs(node: HTMLElement, parentClass: string, - parentNode: HTMLElement): boolean { + parentNode: HTMLElement): boolean { let candidate: HTMLElement | null = node; // Traverse DOM until drag widget encountered: while (candidate && !candidate.classList.contains(parentClass)) { @@ -132,9 +132,8 @@ function belongsToUs(node: HTMLElement, parentClass: string, export function findChild(parent: HTMLElement | HTMLElement[], node: HTMLElement): HTMLElement | null { // Work our way up the DOM to an element which has this node as parent - let child: HTMLElement | null = null; - let parentIsArray = Array.isArray(parent); - let isDirectChild = (child: HTMLElement): boolean => { + const parentIsArray = Array.isArray(parent); + const isDirectChild = (child: HTMLElement): boolean => { if (parentIsArray) { return (parent as HTMLElement[]).indexOf(child) > -1; } else { @@ -142,6 +141,7 @@ function findChild(parent: HTMLElement | HTMLElement[], node: HTMLElement): HTML } }; let candidate: HTMLElement | null = node; + let child: HTMLElement | null = null; while (candidate && candidate !== parent) { if (isDirectChild(candidate)) { child = candidate; @@ -206,20 +206,20 @@ abstract class DropWidget extends Widget { */ handleEvent(event: Event): void { switch (event.type) { - case 'lm-dragenter': - this._evtDragEnter(event as IDragEvent); - break; - case 'lm-dragleave': - this._evtDragLeave(event as IDragEvent); - break; - case 'lm-dragover': - this._evtDragOver(event as IDragEvent); - break; - case 'lm-drop': - this.evtDrop(event as IDragEvent); - break; - default: - break; + case "lm-dragenter": + this._evtDragEnter(event as IDragEvent); + break; + case "lm-dragleave": + this._evtDragLeave(event as IDragEvent); + break; + case "lm-dragover": + this._evtDragOver(event as IDragEvent); + break; + case "lm-drop": + this.evtDrop(event as IDragEvent); + break; + default: + break; } } @@ -250,7 +250,7 @@ abstract class DropWidget extends Widget { return null; } - if (this._isValidTargetHeader(input, mimeData.getData(TABLE_HEADER_MIME))) { + if (this._isValidTargetHeader(input, mimeData.getData(TABLE_HEADER_MIME) as string)) { input.classList.add(DROP_TARGET_CLASS); } else { return null; @@ -258,7 +258,7 @@ abstract class DropWidget extends Widget { // No need to findChild for reordering of columns if (mimeData.types().includes(TABLE_HEADER_MIME)) { - return input; + return input; } else { return findChild(this.node, input); } @@ -288,7 +288,7 @@ abstract class DropWidget extends Widget { // If configured to, only accept internal moves: if (!this.validateSource(event)) { - event.dropAction = 'none'; + event.dropAction = "none"; event.preventDefault(); event.stopPropagation(); return; @@ -301,22 +301,22 @@ abstract class DropWidget extends Widget { * Handle `after_attach` messages for the widget. */ protected onAfterAttach(msg: Message): void { - let node = this.node; - node.addEventListener('lm-dragenter', this); - node.addEventListener('lm-dragleave', this); - node.addEventListener('lm-dragover', this); - node.addEventListener('lm-drop', this); + const node = this.node; + node.addEventListener("lm-dragenter", this); + node.addEventListener("lm-dragleave", this); + node.addEventListener("lm-dragover", this); + node.addEventListener("lm-drop", this); } /** * Handle `before_detach` messages for the widget. */ protected onBeforeDetach(msg: Message): void { - let node = this.node; - node.removeEventListener('lm-dragenter', this); - node.removeEventListener('lm-dragleave', this); - node.removeEventListener('lm-dragover', this); - node.removeEventListener('lm-drop', this); + const node = this.node; + node.removeEventListener("lm-dragenter", this); + node.removeEventListener("lm-dragleave", this); + node.removeEventListener("lm-dragover", this); + node.removeEventListener("lm-drop", this); } @@ -327,7 +327,7 @@ abstract class DropWidget extends Widget { if (!this.validateSource(event)) { return; } - let target = this.findDropTarget(event.target as HTMLElement, event.mimeData); + const target = this.findDropTarget(event.target as HTMLElement, event.mimeData); if (target === null) { return; } @@ -354,7 +354,7 @@ abstract class DropWidget extends Widget { return; } this._clearDropTarget(); - let target = this.findDropTarget(event.target as HTMLElement, event.mimeData); + const target = this.findDropTarget(event.target as HTMLElement, event.mimeData); if (target === null) { return; } @@ -367,9 +367,9 @@ abstract class DropWidget extends Widget { * Checks if the target is a header, and it is not 'path' or itself */ private _isValidTargetHeader(target: HTMLElement, draggedColumn: string) { - return target.classList.contains('tf-header-name') && - target.innerText !== draggedColumn && - target.innerText !== 'path' + return target.classList.contains("tf-header-name") && + target.innerText !== draggedColumn && + target.innerText !== "path"; } /** @@ -379,12 +379,12 @@ abstract class DropWidget extends Widget { * This function assumes there are only one active drop target */ private _clearDropTarget(): void { - let elements = this.node.getElementsByClassName(DROP_TARGET_CLASS); + const elements = this.node.getElementsByClassName(DROP_TARGET_CLASS); if (elements.length) { (elements[0] as HTMLElement).classList.remove(DROP_TARGET_CLASS); } } -}; +} /** * An internal base class for implementing drag operations on top @@ -422,18 +422,18 @@ abstract class DragDropWidgetBase extends DropWidget { */ handleEvent(event: Event): void { switch (event.type) { - case 'mousedown': - this._evtDragMousedown(event as MouseEvent); - break; - case 'mouseup': - this._evtDragMouseup(event as MouseEvent); - break; - case 'mousemove': - this._evtDragMousemove(event as MouseEvent); - break; - default: - super.handleEvent(event); - break; + case "mousedown": + this._evtDragMousedown(event as MouseEvent); + break; + case "mouseup": + this._evtDragMouseup(event as MouseEvent); + break; + case "mousemove": + this._evtDragMousemove(event as MouseEvent); + break; + default: + super.handleEvent(event); + break; } } @@ -459,7 +459,7 @@ abstract class DragDropWidgetBase extends DropWidget { * The default implementation returns a clone of the drag target. */ protected getDragImage(handle: HTMLElement): HTMLElement | null { - let target = this.findDragTarget(handle); + const target = this.findDragTarget(handle); if (target) { return target.cloneNode(true) as HTMLElement; } @@ -477,8 +477,8 @@ abstract class DragDropWidgetBase extends DropWidget { * Handle `after_attach` messages for the widget. */ protected onAfterAttach(msg: Message): void { - let node = this.node; - node.addEventListener('mousedown', this); + const node = this.node; + node.addEventListener("mousedown", this); super.onAfterAttach(msg); } @@ -486,11 +486,11 @@ abstract class DragDropWidgetBase extends DropWidget { * Handle `before_detach` messages for the widget. */ protected onBeforeDetach(msg: Message): void { - let node = this.node; - node.removeEventListener('click', this); - node.removeEventListener('dblclick', this); - document.removeEventListener('mousemove', this, true); - document.removeEventListener('mouseup', this, true); + const node = this.node; + node.removeEventListener("click", this); + node.removeEventListener("dblclick", this); + document.removeEventListener("mousemove", this, true); + document.removeEventListener("mouseup", this, true); super.onBeforeDetach(msg); } @@ -504,7 +504,7 @@ abstract class DragDropWidgetBase extends DropWidget { */ protected startDrag(handle: HTMLElement, clientX: number, clientY: number): void { // Create the drag image. - let dragImage = this.getDragImage(handle); + const dragImage = this.getDragImage(handle); // Set up the drag event. this.drag = new Drag({ @@ -512,14 +512,14 @@ abstract class DragDropWidgetBase extends DropWidget { mimeData: new MimeData(), supportedActions: this.defaultSupportedActions, proposedAction: this.defaultProposedAction, - source: this + source: this, }); this.addMimeData(handle, this.drag.mimeData); // Start the drag and remove the mousemove listener. - this.drag.start(clientX, clientY).then(this.onDragComplete.bind(this)); - document.removeEventListener('mousemove', this, true); - document.removeEventListener('mouseup', this, true); + void this.drag.start(clientX, clientY).then(this.onDragComplete.bind(this)); + document.removeEventListener("mousemove", this, true); + document.removeEventListener("mouseup", this, true); } /** @@ -547,7 +547,7 @@ abstract class DragDropWidgetBase extends DropWidget { } // Finally, check that handle does not belong to a nested drag widget if (handle !== null && !belongsToUs( - handle, DRAG_WIDGET_CLASS, this.node)) { + handle, DRAG_WIDGET_CLASS, this.node)) { // Handle belongs to a nested drag widget: handle = null; } @@ -558,8 +558,8 @@ abstract class DragDropWidgetBase extends DropWidget { * Handle the `'mousedown'` event for the widget. */ private _evtDragMousedown(event: MouseEvent): void { - let target = event.target as HTMLElement; - let handle = this._findDragHandle(target); + const target = event.target as HTMLElement; + const handle = this._findDragHandle(target); if (handle === null) { return; } @@ -567,9 +567,9 @@ abstract class DragDropWidgetBase extends DropWidget { // Left mouse press for drag start. if (event.button === 0) { this._clickData = { pressX: event.clientX, pressY: event.clientY, - handle: handle }; - document.addEventListener('mouseup', this, true); - document.addEventListener('mousemove', this, true); + handle }; + document.addEventListener("mouseup", this, true); + document.addEventListener("mousemove", this, true); event.preventDefault(); } } @@ -580,8 +580,8 @@ abstract class DragDropWidgetBase extends DropWidget { */ private _evtDragMouseup(event: MouseEvent): void { if (event.button !== 0 || !this.drag) { - document.removeEventListener('mousemove', this, true); - document.removeEventListener('mouseup', this, true); + document.removeEventListener("mousemove", this, true); + document.removeEventListener("mouseup", this, true); this.drag = null; return; } @@ -602,12 +602,12 @@ abstract class DragDropWidgetBase extends DropWidget { event.stopPropagation(); // Check for a drag initialization. - let data = this._clickData; + const data = this._clickData; if (!data) { - throw new Error('Missing drag data'); + throw new Error("Missing drag data"); } - let dx = Math.abs(event.clientX - data.pressX); - let dy = Math.abs(event.clientY - data.pressY); + const dx = Math.abs(event.clientX - data.pressX); + const dy = Math.abs(event.clientY - data.pressY); if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) { return; } @@ -616,8 +616,8 @@ abstract class DragDropWidgetBase extends DropWidget { this._clickData = null; } - protected defaultSupportedActions: SupportedActions = 'all'; - protected defaultProposedAction: DropAction = 'move'; + protected defaultSupportedActions: SupportedActions = "all"; + protected defaultProposedAction: DropAction = "move"; /** * Data stored on mouse down to determine if drag treshold has @@ -749,18 +749,18 @@ export abstract class DragDropWidget extends DragDropWidgetBase { * Override this if you need to handle other mime data than the default. */ protected processDrop(dropTarget: HTMLElement, event: IDragEvent): void { - if (!DropWidget.isValidAction(event.supportedActions, 'move') || - event.proposedAction === 'none') { + if (!DropWidget.isValidAction(event.supportedActions, "move") || + event.proposedAction === "none") { // The default implementation only handles move action // OR Accept proposed none action, and perform no-op - event.dropAction = 'none'; + event.dropAction = "none"; event.preventDefault(); event.stopPropagation(); return; } if (!this.validateSource(event)) { // Source indicates external drop, incorrect use in subclass - throw new Error('Invalid source!'); + throw new Error("Invalid source!"); } // We have an acceptable drop, handle: @@ -772,7 +772,6 @@ export abstract class DragDropWidget extends DragDropWidgetBase { } - /** * The namespace for the `DropWidget` class statics. */ @@ -804,16 +803,16 @@ namespace DropWidget { export function isValidAction(supported: SupportedActions, action: DropAction): boolean { switch (supported) { - case 'all': - return true; - case 'link-move': - return action === 'move' || action === 'link'; - case 'copy-move': - return action === 'move' || action === 'copy'; - case 'copy-link': - return action === 'link' || action === 'copy'; - default: - return action === supported; + case "all": + return true; + case "link-move": + return action === "move" || action === "link"; + case "copy-move": + return action === "move" || action === "copy"; + case "copy-link": + return action === "link" || action === "copy"; + default: + return action === supported; } } } @@ -856,7 +855,7 @@ namespace DragWidget { */ export function createDefaultHandle(): Widget { - let widget = new Widget(); + const widget = new Widget(); widget.addClass(DEFAULT_DRAG_HANDLE_CLASS); makeHandle(widget); return widget; @@ -875,7 +874,6 @@ namespace DragDropWidget { } - export abstract class FriendlyDragDrop extends DragDropWidget { private static _counter = 0; @@ -898,7 +896,7 @@ abstract class FriendlyDragDrop extends DragDropWidget { get friends(): FriendlyDragDrop[] { if (this._groupId === undefined) { - throw new Error('Uninitialized drag-drop group'); + throw new Error("Uninitialized drag-drop group"); } return FriendlyDragDrop._groups[this._groupId]; } @@ -919,7 +917,7 @@ abstract class FriendlyDragDrop extends DragDropWidget { // */ // export class DragDropContents extends FriendlyDragDrop { -// protected move(mimeData: MimeData, target: HTMLElement): DropAction { +// protected move(mimeData: MimeData, target: HTMLElement): DropAction { // if (!mimeData.hasData(CONTENTS_MIME)) { // return 'none'; // } @@ -927,9 +925,9 @@ abstract class FriendlyDragDrop extends DragDropWidget { // } // protected addMimeData(handle: HTMLElement, mimeData: MimeData): void { - + // } - + // /** // * Handle the `'lm-dragenter'` event for the widget. @@ -1086,4 +1084,4 @@ abstract class FriendlyDragDrop extends DragDropWidget { // clearTimeout(this._selectTimer); // }); // } -// } \ No newline at end of file +// } diff --git a/js/src/filesystem.ts b/js/src/filesystem.ts index deff7c0e..f8a18272 100644 --- a/js/src/filesystem.ts +++ b/js/src/filesystem.ts @@ -43,7 +43,7 @@ export interface IFSResource { /** * Directory to be first opened */ - preferred_dir?: string; + preferred_dir?: string; /** * The jupyterlab drive name associated with this resource. This is defined diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index 814528f5..9382dab0 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -89,7 +89,7 @@ export class TreeFinderWidget extends DragDropWidget { columns, rootPath = "", translator, - settings + settings, }: TreeFinderWidget.IOptions) { const { commands, serviceManager: { contents } } = app; @@ -109,21 +109,21 @@ export class TreeFinderWidget extends DragDropWidget { this.rootPath = rootPath === "" ? rootPath : rootPath + ":"; // CAREFUL: tree-finder currently REQUIRES the node to be added to the DOM before init can be called! this._ready = this.nodeInit(); - this._ready.catch(reason => showErrorMessage("Failed to init browser", reason as string)); - this._ready.then(() => { + void this._ready.catch(reason => showErrorMessage("Failed to init browser", reason as string)); + void this._ready.then(() => { // TODO: Model state of TreeFinderWidget should be updated by renamerSub process. // Currently we hard-code the refresh here, but should be moved upstream! const contentsModel = this.model!; - contentsModel.renamerSub.subscribe(async ({ name, target }) => { - contentsModel.sort(); + contentsModel.renamerSub.subscribe(({ name, target }) => { + void contentsModel.sort(); }); - }) + }); } protected move(mimeData: MimeData, target: HTMLElement): DropAction { const source = mimeData.getData(TABLE_HEADER_MIME) as (keyof ContentsProxy.IJupyterContentRow); const dest = target.innerText as (keyof ContentsProxy.IJupyterContentRow); - void this._reorderColumns(source, dest) + void this._reorderColumns(source, dest); void this.nodeInit(); return "move"; } @@ -134,11 +134,11 @@ export class TreeFinderWidget extends DragDropWidget { } protected getDragImage(handle: HTMLElement): HTMLElement | null { - let target = this.findDragTarget(handle); + const target = this.findDragTarget(handle); let img = null; if (target) { img = target.cloneNode(true) as HTMLElement; - img.classList.add('jp-thead-drag-image'); + img.classList.add("jp-thead-drag-image"); } return img; } @@ -155,8 +155,7 @@ export class TreeFinderWidget extends DragDropWidget { if (sIndex < dIndex) { this._columns.splice(dIndex + 1, 0, source); this._columns.splice(sIndex, 1); - } - else if (sIndex > dIndex) { + } else if (sIndex > dIndex) { this._columns.splice(sIndex, 1); this._columns.splice(dIndex, 0, source); } @@ -208,7 +207,7 @@ export class TreeFinderWidget extends DragDropWidget { if (tableHeader) { // If tableheader is path, do not make it draggable - if (tableHeader.innerText !== 'path') { + if (tableHeader.innerText !== "path") { tableHeader.classList.add(this.dragHandleClass); } } @@ -389,7 +388,7 @@ export class TreeFinderWidget extends DragDropWidget { protected evtKeydown(event: KeyboardEvent): void { // handle any keys unaffacted by renaming status above this check: if (this.parent?.node.classList.contains("jfs-mod-renaming")) { - return + return; } switch (event.key) { case "ArrowDown": @@ -402,15 +401,15 @@ export class TreeFinderWidget extends DragDropWidget { if (this.model?.selectedLast) { event.stopPropagation(); event.preventDefault(); - let selectedLast = this.model.selectedLast; + const selectedLast = this.model.selectedLast; // don't allow expansion or up/down nav if in select range mode: if (!event.shiftKey) { if (selectedLast.isExpand) { - this.model.collapse(this.model.contents.indexOf(selectedLast)); + void this.model.collapse(this.model.contents.indexOf(selectedLast)); } else { // navigate the selection to the next up (exluding to root) void getContentParent(selectedLast, this.model.root).then(parent => { - if (parent != this.model?.root) { + if (parent !== this.model?.root) { this.model?.selectionModel.select(parent); return TreeFinderSidebar.scrollIntoView(this, parent.pathstr); } @@ -423,16 +422,16 @@ export class TreeFinderWidget extends DragDropWidget { if (this.model?.selectedLast) { event.stopPropagation(); event.preventDefault(); - let selectedLast = this.model.selectedLast; + const selectedLast = this.model.selectedLast; // don't allow expansion or up/down nav if in select range mode: if (!event.shiftKey) { if (!selectedLast.isExpand) { - this.model.expand(this.model.contents.indexOf(selectedLast)); + void this.model.expand(this.model.contents.indexOf(selectedLast)); } else if (selectedLast.hasChildren) { // navigate the selection to the first child void selectedLast.getChildren().then(children => { if (children && children.length > 0) { - this.model?.selectionModel.select(children[0]) + this.model?.selectionModel.select(children[0]); return TreeFinderSidebar.scrollIntoView(this, children[0].pathstr); } }); @@ -445,13 +444,13 @@ export class TreeFinderWidget extends DragDropWidget { if (this.model?.selectedLast) { event.stopPropagation(); event.preventDefault(); - let selectedLast = this.model.selectedLast; + const selectedLast = this.model.selectedLast; if (selectedLast.hasChildren) { - let selectedIdx = this.model.contents.indexOf(selectedLast); + const selectedIdx = this.model.contents.indexOf(selectedLast); if (selectedLast.isExpand) { - this.model.collapse(selectedIdx); + void this.model.collapse(selectedIdx); } else { - this.model.expand(selectedIdx); + void this.model.expand(selectedIdx); } } } @@ -550,7 +549,7 @@ export class TreeFinderSidebar extends Widget { rootPath = "", caption = "TreeFinder", id = "jupyterlab-tree-finder", - settings + settings, }: TreeFinderSidebar.IOptions) { super(); this.id = id; @@ -738,13 +737,13 @@ export namespace TreeFinderSidebar { if (preferredDir) { void widget.treefinder.ready.then(async () => { - var path = preferredDir.split("/"); + let path = preferredDir.split("/"); if (preferredDir.startsWith("/")) { path = path.slice(1); - }; + } path.unshift(rootPath); - openDirRecursive(widget.treefinder.model!, path); - }) + await openDirRecursive(widget.treefinder.model!, path); + }); } // // remove context highlight on context menu exit From ff575a75d663d84a43ea1433568a970c155d5df0 Mon Sep 17 00:00:00 2001 From: Jacob Frazer <64307592+jacob-frazer@users.noreply.github.com> Date: Tue, 1 Aug 2023 10:21:28 +0100 Subject: [PATCH 17/49] Change get to use info --- jupyterfs/fsmanager.py | 66 ++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/jupyterfs/fsmanager.py b/jupyterfs/fsmanager.py index 953fbc46..58e0f714 100644 --- a/jupyterfs/fsmanager.py +++ b/jupyterfs/fsmanager.py @@ -146,7 +146,7 @@ def __init__(self, pyfs, *args, default_writable=True, parent=None, **kwargs): def _checkpoints_class_default(self): return NullCheckpoints - def _is_path_hidden(self, path): + def _is_path_hidden(self, path, info): """Does the specific API style path correspond to a hidden node? Args: path (str): The path to check. @@ -161,7 +161,9 @@ def _is_path_hidden(self, path): return True try: - info = self._pyfilesystem_instance.getinfo(path, namespaces=("stat",)) + if not info: + info = self._pyfilesystem_instance.getinfo(path, namespaces=("stat",)) + # Check Windows flag: if info.get("stat", "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_HIDDEN: return True @@ -187,10 +189,11 @@ def _is_path_hidden(self, path): self.log.exception(f"Failed to check if path is hidden: {path!r}") return False - def is_hidden(self, path): + def is_hidden(self, path, info=None): """Does the API style path correspond to a hidden directory or file? Args: path (str): The path to check. + info (): fs info object - if passed used instead of path Returns: hidden (bool): Whether the path or any of its parents are hidden. """ @@ -199,7 +202,7 @@ def is_hidden(self, path): if any(part.startswith(".") for part in ppath.parts): return True while ppath.parents: - if self._is_path_hidden(str(path)): + if self._is_path_hidden(str(path), info): return True ppath = ppath.parent return False @@ -231,18 +234,13 @@ def exists(self, path): """ return self._pyfilesystem_instance.exists(path) - def _base_model(self, path, info=None): + def _base_model(self, path, info): """ Build the common base of a contents model - if `info` passed, then that FS.Info object is used for values instead of getinfo on provided path - this saves a getinfo call and should speed up processing when used with scandir (which returns Info objs) + `info`: FS.Info object for file/dir -- used for values instead of getinfo on provided path + """ - if not info: - info = self._pyfilesystem_instance.getinfo( - path, namespaces=["details", "access"] - ) - try: # size of file size = info.size @@ -295,19 +293,21 @@ def _base_model(self, path, info=None): return model - def _dir_model(self, path, content=True, info=None): + def _dir_model(self, path, info, content=True): """Build a model for a directory if content is requested, will include a listing of the directory if `info` passed, given to _base_model so it doesn't need to make getinfo request on `path` """ four_o_four = "directory does not exist: %r" % path - if not self._pyfilesystem_instance.isdir(path): + if not info.is_dir: raise web.HTTPError(404, four_o_four) - elif not self.allow_hidden and self.is_hidden(path): + + elif not self.allow_hidden and self.is_hidden(path, info): self.log.debug("Refusing to serve hidden directory %r, via 404 Error", path) raise web.HTTPError(404, four_o_four) + model = self._base_model(path, info) model["type"] = "directory" model["size"] = None @@ -317,7 +317,7 @@ def _dir_model(self, path, content=True, info=None): for dir_entry in self._pyfilesystem_instance.scandir(path, namespaces=("basic", "access", "details")): try: if self.should_list(dir_entry.name): - if self.allow_hidden or not self._is_path_hidden(dir_entry.name): + if self.allow_hidden or not self._is_path_hidden(dir_entry.name, dir_entry): contents.append( self.get(path="%s/%s" % (path, dir_entry.name), content=False, info=dir_entry) ) @@ -333,7 +333,7 @@ def _dir_model(self, path, content=True, info=None): model["format"] = "json" return model - def _read_file(self, path, format): + def _read_file(self, path, format, info): """Read a non-notebook file. Args: path (str): The path to be read. @@ -341,9 +341,10 @@ def _read_file(self, path, format): If 'text', the contents will be decoded as UTF-8. If 'base64', the raw bytes contents will be encoded as base64. If not specified, try to decode as UTF-8, and fall back to base64 + info (): Info object for file at path """ with self.perm_to_403(path): - if not self._pyfilesystem_instance.isfile(path): + if not info.is_file: raise web.HTTPError(400, "Cannot read non-file %s" % path) bcontent = self._pyfilesystem_instance.readbytes(path) @@ -362,12 +363,12 @@ def _read_file(self, path, format): ) return encodebytes(bcontent).decode("ascii"), "base64" - def _read_notebook(self, path, as_version=4): + def _read_notebook(self, path, info, as_version=4): """Read a notebook from a path.""" - nb, format = self._read_file(path, "text") + nb, format = self._read_file(path, "text", info) return nbformat.reads(nb, as_version=as_version) - def _file_model(self, path, content=True, format=None, info=None): + def _file_model(self, path, info, content=True, format=None): """Build a model for a file if content is requested, include the file contents. format: @@ -375,14 +376,14 @@ def _file_model(self, path, content=True, format=None, info=None): If 'base64', the raw bytes contents will be encoded as base64. If not specified, try to decode as UTF-8, and fall back to base64 - if `info` passed, given to _base_model so it doesn't need to make getinfo request on `path` + info: fs object for file at path - by passing it avoid extra network requests """ model = self._base_model(path, info) model["type"] = "file" model["mimetype"] = mimetypes.guess_type(path)[0] if content: - content, format = self._read_file(path, format) + content, format = self._read_file(path, format, info) if model["mimetype"] is None: default_mime = { "text": "text/plain", @@ -397,16 +398,16 @@ def _file_model(self, path, content=True, format=None, info=None): return model - def _notebook_model(self, path, content=True, info=None): + def _notebook_model(self, path, info, content=True): """Build a notebook model if content is requested, the notebook content will be populated as a JSON structure (not double-serialized) - if `info` passed, given to _base_model so it doesn't need to make getinfo request on `path` + `info` fs object for file at path, passing it avoids extra network requests """ model = self._base_model(path, info) model["type"] = "notebook" if content: - nb = self._read_notebook(path, as_version=4) + nb = self._read_notebook(path, info, as_version=4) self.mark_trusted_cells(nb, path) model["content"] = nb model["format"] = "json" @@ -420,17 +421,20 @@ def get(self, path, content=True, type=None, format=None, info=None): content (bool): Whether to include the contents in the reply type (str): The requested type - 'file', 'notebook', or 'directory'. Will raise HTTPError 400 if the content doesn't match. format (str): The requested format for file contents. 'text' or 'base64'. Ignored if this returns a notebook or directory model. - info (fs Info object): FS Info directly rather than path, if present no need to call getinfo on path -- combined with scandir should - improve efficiency saving some amount of network calls as needed info is cached in object + info (fs Info object): FS Info directly rather than path, if present no need to call getinfo on path Returns model (dict): the contents model. If content=True, returns the contents of the file or directory as well. """ path = path.strip("/") - if not self.exists(path): - raise web.HTTPError(404, "No such file or directory: %s" % path) + # gather info - by doing here can minimise further network requests from underlying fs functions + if not info: + try: + info = self._pyfilesystem_instance.getinfo(path, namespaces=("basic", "stat", "access", "details")) + except: + raise web.HTTPError(404, "No such file or directory: %s" % path) - if self._pyfilesystem_instance.isdir(path): + if info.is_dir: if type not in (None, "directory"): raise web.HTTPError( 400, "%s is a directory, not a %s" % (path, type), reason="bad type" From e3b2d3743733b650d006ef467dffd00ce156a2f7 Mon Sep 17 00:00:00 2001 From: Jacob Frazer <64307592+jacob-frazer@users.noreply.github.com> Date: Tue, 1 Aug 2023 16:24:32 +0100 Subject: [PATCH 18/49] Improve doc strings --- jupyterfs/fsmanager.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/jupyterfs/fsmanager.py b/jupyterfs/fsmanager.py index 58e0f714..5af11583 100644 --- a/jupyterfs/fsmanager.py +++ b/jupyterfs/fsmanager.py @@ -150,6 +150,7 @@ def _is_path_hidden(self, path, info): """Does the specific API style path correspond to a hidden node? Args: path (str): The path to check. + info (): FS Info object for file/dir at path Returns: hidden (bool): Whether the path is hidden. """ @@ -193,7 +194,7 @@ def is_hidden(self, path, info=None): """Does the API style path correspond to a hidden directory or file? Args: path (str): The path to check. - info (): fs info object - if passed used instead of path + info (): FS Info object for file/dir at path Returns: hidden (bool): Whether the path or any of its parents are hidden. """ @@ -238,8 +239,7 @@ def _base_model(self, path, info): """ Build the common base of a contents model - `info`: FS.Info object for file/dir -- used for values instead of getinfo on provided path - + info (): FS Info object for file/dir at path -- used for values and reduces needed network calls """ try: # size of file @@ -296,7 +296,7 @@ def _base_model(self, path, info): def _dir_model(self, path, info, content=True): """Build a model for a directory if content is requested, will include a listing of the directory - if `info` passed, given to _base_model so it doesn't need to make getinfo request on `path` + info (): FS Info object for file/dir at path """ four_o_four = "directory does not exist: %r" % path @@ -314,7 +314,7 @@ def _dir_model(self, path, info, content=True): if content: model["content"] = contents = [] - for dir_entry in self._pyfilesystem_instance.scandir(path, namespaces=("basic", "access", "details")): + for dir_entry in self._pyfilesystem_instance.scandir(path, namespaces=("basic", "access", "details", "stat")): try: if self.should_list(dir_entry.name): if self.allow_hidden or not self._is_path_hidden(dir_entry.name, dir_entry): @@ -341,7 +341,7 @@ def _read_file(self, path, format, info): If 'text', the contents will be decoded as UTF-8. If 'base64', the raw bytes contents will be encoded as base64. If not specified, try to decode as UTF-8, and fall back to base64 - info (): Info object for file at path + info (): FS Info object for file at path """ with self.perm_to_403(path): if not info.is_file: @@ -376,7 +376,7 @@ def _file_model(self, path, info, content=True, format=None): If 'base64', the raw bytes contents will be encoded as base64. If not specified, try to decode as UTF-8, and fall back to base64 - info: fs object for file at path - by passing it avoid extra network requests + info (): FS Info object for file at path """ model = self._base_model(path, info) model["type"] = "file" @@ -402,7 +402,7 @@ def _notebook_model(self, path, info, content=True): """Build a notebook model if content is requested, the notebook content will be populated as a JSON structure (not double-serialized) - `info` fs object for file at path, passing it avoids extra network requests + info (): FS Info object for file at path """ model = self._base_model(path, info) model["type"] = "notebook" @@ -421,7 +421,7 @@ def get(self, path, content=True, type=None, format=None, info=None): content (bool): Whether to include the contents in the reply type (str): The requested type - 'file', 'notebook', or 'directory'. Will raise HTTPError 400 if the content doesn't match. format (str): The requested format for file contents. 'text' or 'base64'. Ignored if this returns a notebook or directory model. - info (fs Info object): FS Info directly rather than path, if present no need to call getinfo on path + info (fs Info object): Optional FS Info. If present, it needs to include the following namespaces: "basic", "stat", "access", "details". Including it can avoid extraneous networkcalls. Returns model (dict): the contents model. If content=True, returns the contents of the file or directory as well. """ From 5a48661a516b578b4653dcc626858c8a0729d0fc Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Tue, 1 Aug 2023 19:21:13 +0100 Subject: [PATCH 19/49] Add load spinners Adds some basic CSS spinners for when a content get is slow. Currently we always add it, but it will be throttled by animation frames / draw requests to regular-table. Ideally we would have an initial wait, but it would require managing timers. So there might be some flicker when using a quick backend (but it will happen ~at the same time as content comes in, so not too disruptive). It is a little hackish, as we are tying into to contents proxy, instead of the actual `expand` action in the grid, but that doesn't currently have any hooks that we can use, so this is best effort. The CSS spinners are simple in design since they are in the ::before pseudo-element, so advanced structures like the JupyterLab Spinner component are hard to reuse/replicate. --- js/src/contents_proxy.ts | 28 ++++++++++++++----- js/src/treefinder.ts | 49 ++++++++++++++++++++++++++++++-- js/style/treefinder.css | 60 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/js/src/contents_proxy.ts b/js/src/contents_proxy.ts index ad52e2ef..303d2ecf 100644 --- a/js/src/contents_proxy.ts +++ b/js/src/contents_proxy.ts @@ -1,4 +1,6 @@ + +import { PromiseDelegate } from "@lumino/coreutils"; import { showErrorMessage } from "@jupyterlab/apputils"; import { Contents, ContentsManager } from "@jupyterlab/services"; import { IContentRow, Path } from "tree-finder"; @@ -8,30 +10,31 @@ import { IContentRow, Path } from "tree-finder"; * Wrapper for a drive onto the contents manager. */ export class ContentsProxy { - constructor(contentsManager: ContentsManager, drive?: string) { + constructor(contentsManager: ContentsManager, drive?: string, onGetChildren?: ContentsProxy.GetChildrenCallback) { this.contentsManager = contentsManager; this.drive = drive; + this.onGetChildren = onGetChildren; } async get(path: string, options?: Contents.IFetchOptions) { path = ContentsProxy.toFullPath(path, this.drive); - return ContentsProxy.toJupyterContentRow(await this.contentsManager.get(path, options), this.contentsManager, this.drive); + return ContentsProxy.toJupyterContentRow(await this.contentsManager.get(path, options), this.contentsManager, this.drive, this.onGetChildren); } async save(path: string, options?: Partial) { path = ContentsProxy.toFullPath(path, this.drive); - return ContentsProxy.toJupyterContentRow(await this.contentsManager.save(path, options), this.contentsManager, this.drive); + return ContentsProxy.toJupyterContentRow(await this.contentsManager.save(path, options), this.contentsManager, this.drive, this.onGetChildren); } async rename(path: string, newPath: string) { path = ContentsProxy.toFullPath(path, this.drive); newPath = ContentsProxy.toFullPath(newPath, this.drive); - return ContentsProxy.toJupyterContentRow(await this.contentsManager.rename(path, newPath), this.contentsManager, this.drive); + return ContentsProxy.toJupyterContentRow(await this.contentsManager.rename(path, newPath), this.contentsManager, this.drive, this.onGetChildren); } async newUntitled(options: Contents.ICreateOptions) { options.path = options.path && ContentsProxy.toFullPath(options.path, this.drive); - return ContentsProxy.toJupyterContentRow(await this.contentsManager.newUntitled(options), this.contentsManager, this.drive); + return ContentsProxy.toJupyterContentRow(await this.contentsManager.newUntitled(options), this.contentsManager, this.drive, this.onGetChildren); } async downloadUrl(path: string) { @@ -41,11 +44,14 @@ export class ContentsProxy { readonly contentsManager: ContentsManager; readonly drive?: string; + readonly onGetChildren?: ContentsProxy.GetChildrenCallback; } export namespace ContentsProxy { export interface IJupyterContentRow extends Omit, IContentRow {} + export type GetChildrenCallback = (path: string, done: Promise) => void; + export function toFullPath(path: string, drive?: string): string { if (!drive || path.startsWith(`${drive}:`)) { @@ -65,7 +71,7 @@ export namespace ContentsProxy { return [first.split(":").pop(), ...rest].join("/"); } - export function toJupyterContentRow(row: Contents.IModel, contentsManager: ContentsManager, drive?: string): IJupyterContentRow { + export function toJupyterContentRow(row: Contents.IModel, contentsManager: ContentsManager, drive?: string, onGetChildren?: ContentsProxy.GetChildrenCallback): IJupyterContentRow { const { path, type, ...rest } = row; const pathWithDrive = toFullPath(path, drive).replace(/\/$/, ""); @@ -78,13 +84,21 @@ export namespace ContentsProxy { ...(kind === "dir" ? { getChildren: async () => { let contents: Contents.IModel; + const done = new PromiseDelegate(); + if (onGetChildren) { + const pathstr = Path.toarray(pathWithDrive).join("/"); // maybe clean up the different path formats we have... + onGetChildren(pathstr, done.promise); + } + try { contents = await contentsManager.get(pathWithDrive, { content: true }); + done.resolve(); } catch (error) { void showErrorMessage("Failed to get directory contents", error as string); + done.reject("Failed to get directory contents"); return []; } - return (contents.content as Contents.IModel[]).map(c => toJupyterContentRow(c, contentsManager, drive)); + return (contents.content as Contents.IModel[]).map(c => toJupyterContentRow(c, contentsManager, drive, onGetChildren)); }, }: {}), }; diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index 9382dab0..141e91c2 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -98,15 +98,19 @@ export class TreeFinderWidget extends DragDropWidget { super({ node, acceptedDropMimeTypes }); this.addClass("jp-tree-finder"); - this.contentsProxy = new ContentsProxy(contents as ContentsManager, rootPath); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.contentsProxy = new ContentsProxy(contents as ContentsManager, rootPath, this.onGetChildren.bind(this)); this.settings = settings; this.translator = translator || nullTranslator; this._trans = this.translator.load("jupyterlab"); - this._commands = commands; + this._commands = commands; + this._expanding = new Map(); this._columns = columns; this.rootPath = rootPath === "" ? rootPath : rootPath + ":"; + this._initialLoad = true; + // CAREFUL: tree-finder currently REQUIRES the node to be added to the DOM before init can be called! this._ready = this.nodeInit(); void this._ready.catch(reason => showErrorMessage("Failed to init browser", reason as string)); @@ -143,6 +147,30 @@ export class TreeFinderWidget extends DragDropWidget { return img; } + protected onGetChildren(path: string, done: Promise) { + if (this._initialLoad) { + const rootPathstr = Path.toarray(this.rootPath).join("/"); + if (path === rootPathstr) { + done.finally(() => { + this._initialLoad = false; + this.draw(); + }); + } + } + this._expanding.set(path, (this._expanding.get(path) || 0) + 1); + // only redraw if bumped up from 0 + if (this._expanding.get(path) === 1) { + this.draw(); + } + done.finally(() => { + this._expanding.set(path, (this._expanding.get(path) || 1) - 1); + // only redraw if bumped down to 0 + if (this._expanding.get(path) === 0) { + this.draw(); + } + }); + } + /** * Reorders the columns according to given inputs and saves to user settings * If `source` is dragged from left to right, it will be inserted to the right side of `dest` @@ -172,7 +200,8 @@ export class TreeFinderWidget extends DragDropWidget { } async nodeInit() { - await this.contentsProxy.get(this.rootPath).then(root => this.node.init({ + // The contents of root passed to node.init is not (currently) considered, so do not ask for it.. + await this.contentsProxy.get(this.rootPath, { content: false }).then(root => this.node.init({ root, gridOptions: { columnFormatters: { @@ -189,6 +218,9 @@ export class TreeFinderWidget extends DragDropWidget { })).then(() => { const grid = this.node.querySelector>("tree-finder-grid"); grid?.addStyleListener(() => { + // Set root-level load indicator + grid.classList.toggle("jfs-mod-loading", this._initialLoad); + // Fix corner cleanup (workaround for underlying bug where we end up with two resize handles) const resizeSpans = grid.querySelectorAll(`thead tr > th:first-child > span.rt-column-resize`); const nHeaderRows = grid.querySelectorAll("thead tr").length; @@ -223,6 +255,15 @@ export class TreeFinderWidget extends DragDropWidget { lastSelectIdx = -1; } } + + // Add "loading" indicator for folders that are fetching children + if (nameElement) { + const meta = grid.getMeta(rowHeader); + const content = meta?.y ? this.model?.contents[meta.y] : undefined; + if (content) { + rowHeader.classList.toggle("jfs-mod-loading", !!nameElement && (this._expanding.get(content.pathstr) || 0) > 0); + } + } } }); if (this.uploader) { @@ -528,6 +569,8 @@ export class TreeFinderWidget extends DragDropWidget { private _ready: Promise; private _trans: TranslationBundle; private _commands: CommandRegistry; + private _expanding: Map; + private _initialLoad: boolean; } export namespace TreeFinderWidget { diff --git a/js/style/treefinder.css b/js/style/treefinder.css index b5408761..1ffa6b02 100644 --- a/js/style/treefinder.css +++ b/js/style/treefinder.css @@ -84,6 +84,66 @@ tree-finder-panel tree-finder-grid table { font-weight: normal; } +.jp-tree-finder th.jfs-mod-loading span.rt-row-header-icon::before { + content: ""; + background-color: var(--tf-background); + border: solid 3px var(--jp-ui-font-color1); + border-bottom-color: var(--jp-brand-color1); + border-radius: 50%; + + position: absolute; + width: 1ex; + height: 1ex; + + animation: load3 1s infinite linear +} + +.jp-tree-finder tr.tf-mod-select th.jfs-mod-loading span.rt-row-header-icon::before { + background-color: var(--tf-select-background); +} + +.jp-tree-finder tbody tr:hover:not(.tf-mod-select) th.jfs-mod-loading span.rt-row-header-icon::before { + background-color: var(--tf-row-hover-background); +} + +.jp-tree-finder tree-finder-grid.jfs-mod-loading tbody::before { + content: ""; + border: solid 5px var(--jp-brand-color1); + border-radius: 50%; + border-bottom-color: var(--jp-layout-color1); + + position: absolute; + width: 3em; + height: 3em; + left: calc(50% - 2em); + top: 4lh; + + animation: + load3 1s infinite linear, + fadeIn 1s; +} + + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes load3 { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + .jp-tree-finder thead th { font-weight: 500; } From d74e58c29935c013364ecc94941d364b2782ef93 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Fri, 4 Aug 2023 12:30:09 +0100 Subject: [PATCH 20/49] Fix _dir_model hidden check Also adds a sanity check for the `access` check to avoid regressions. --- jupyterfs/fsmanager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jupyterfs/fsmanager.py b/jupyterfs/fsmanager.py index 5af11583..d5c04acf 100644 --- a/jupyterfs/fsmanager.py +++ b/jupyterfs/fsmanager.py @@ -177,7 +177,7 @@ def _is_path_hidden(self, path, info): import os syspath = self._pyfilesystem_instance.getsyspath(path) - if not os.access(syspath, os.X_OK | os.R_OK): + if os.path.exists(syspath) and not os.access(syspath, os.X_OK | os.R_OK): return True except ResourceNotFound: @@ -317,7 +317,7 @@ def _dir_model(self, path, info, content=True): for dir_entry in self._pyfilesystem_instance.scandir(path, namespaces=("basic", "access", "details", "stat")): try: if self.should_list(dir_entry.name): - if self.allow_hidden or not self._is_path_hidden(dir_entry.name, dir_entry): + if self.allow_hidden or not self._is_path_hidden(dir_entry.make_path(path), dir_entry): contents.append( self.get(path="%s/%s" % (path, dir_entry.name), content=False, info=dir_entry) ) From 3561e8c0751d2b64da09fc46c8ec836c34edc51f Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 10 Aug 2023 13:48:24 +0100 Subject: [PATCH 21/49] Refactor commands dynamic vs static --- js/src/commands.ts | 76 +++++++++++++++++++++++++--------------------- js/src/index.tsx | 19 +++++++----- 2 files changed, 53 insertions(+), 42 deletions(-) diff --git a/js/src/commands.ts b/js/src/commands.ts index 8632bdac..a4aa075b 100644 --- a/js/src/commands.ts +++ b/js/src/commands.ts @@ -100,41 +100,16 @@ function _getRelativePaths(selectedFiles: Array clipboard.model.copySelection(tracker.currentWidget!.treefinder.model!), @@ -328,7 +303,33 @@ export function createCommands( isEnabled: () => false, isToggled: () => true, }), - ...COLUMN_NAMES.map((column: keyof ContentsProxy.IJupyterContentRow) => app.commands.addCommand(toggleColumnCommandId(column), { + ].reduce((set: DisposableSet, d) => { + set.add(d); return set; + }, new DisposableSet()); +} + + +/** + * Create commands whose count/IDs depend on settings/resources + */ +export async function createDynamicCommands( + app: JupyterFrontEnd, + tracker: TreeFinderTracker, + clipboard: JupyterClipboard, + resources: IFSResource[], + settings?: ISettingRegistry.ISettings, +): Promise { + const columnCommands = []; + const toggleState: {[key: string]: boolean} = {}; + const colsToDisplay = settings?.composite.display_columns as string[] ?? ["size"]; + const columnsMenu = new Menu({ commands: app.commands }); + columnsMenu.title.label = "Show/Hide Columns"; + columnsMenu.title.icon = filterListIcon; + columnsMenu.addItem({ command: commandIDs.toggleColumnPath }); + for (const column of COLUMN_NAMES) { + columnsMenu.addItem({ command: toggleColumnCommandId(column) }); + toggleState[column] = colsToDisplay.includes(column); + columnCommands.push(app.commands.addCommand(toggleColumnCommandId(column), { execute: async args => { toggleState[column] = !toggleState[column]; await settings?.set("display_columns", COLUMN_NAMES.filter(k => toggleState[k])); @@ -336,7 +337,14 @@ export function createCommands( label: column, isToggleable: true, isToggled: () => toggleState[column], - })), + })); + } + + const selector = ".jp-tree-finder-sidebar"; + let contextMenuRank = 1; + + return [ + ...columnCommands, // context menu items app.contextMenu.addItem({ @@ -412,7 +420,7 @@ export function createCommands( }), app.contextMenu.addItem({ type: "submenu", - submenu, + submenu: columnsMenu, selector, rank: contextMenuRank++, }), diff --git a/js/src/index.tsx b/js/src/index.tsx index 7bd87592..b1a181ed 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -14,12 +14,12 @@ import { ISettingRegistry } from "@jupyterlab/settingregistry"; import { IStatusBar } from "@jupyterlab/statusbar"; import { ITranslator } from "@jupyterlab/translation"; import { folderIcon, fileIcon } from "@jupyterlab/ui-components"; -import { IDisposable } from "@lumino/disposable"; +import { DisposableSet, IDisposable } from "@lumino/disposable"; import * as React from "react"; import * as ReactDOM from "react-dom"; import { AskDialog, askRequired } from "./auth"; -import { createCommands, idFromResource } from "./commands"; +import { commandIDs, createDynamicCommands, createStaticCommands, idFromResource } from "./commands"; import { ContentsProxy } from "./contents_proxy"; import { FSComm, IFSOptions, IFSResource } from "./filesystem"; import { FileUploadStatus } from "./progress"; @@ -79,7 +79,9 @@ export const browser: JupyterFrontEndPlugin = { settings, }; - function refreshWidgets({ resources, options }: {resources: IFSResource[]; options: IFSOptions}) { + createStaticCommands(app, TreeFinderSidebar.tracker, TreeFinderSidebar.clipboard); + + async function refreshWidgets({ resources, options }: {resources: IFSResource[]; options: IFSOptions}) { if (options.verbose) { // eslint-disable-next-line no-console console.info(`jupyter-fs frontend received resources:\n${JSON.stringify(resources)}`); @@ -101,7 +103,7 @@ export const browser: JupyterFrontEndPlugin = { w.treefinder.columns = columns; } } - commands = createCommands( + commands = await createDynamicCommands( app, TreeFinderSidebar.tracker, TreeFinderSidebar.clipboard, @@ -156,7 +158,7 @@ export const browser: JupyterFrontEndPlugin = { options, }); cleanup(); - refreshWidgets({ resources, options }); + await refreshWidgets({ resources, options }); }; ReactDOM.render( @@ -171,9 +173,10 @@ export const browser: JupyterFrontEndPlugin = { } else { // otherwise, just go ahead and refresh the widgets cleanup(); - refreshWidgets({ options, resources }); + await refreshWidgets({ options, resources }); } - } catch { + } catch (e) { + console.error("Failed to refresh widgets!", e); cleanup(true); } } @@ -236,7 +239,7 @@ export const progressStatus: JupyterFrontEndPlugin = { ITreeFinderMain, IStatusBar, ], - async activate( + activate( app: JupyterFrontEnd, translator: ITranslator, main: ITreeFinderMain | null, From f1966e6614b564f2ba58d4d64bdb0319ccd9441d Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 10 Aug 2023 13:42:05 +0100 Subject: [PATCH 22/49] cleanup jest --- js/tests/activate.test.ts | 2 +- js/tests/jest-setup.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 js/tests/jest-setup.js diff --git a/js/tests/activate.test.ts b/js/tests/activate.test.ts index ea15cdda..3a7f56fb 100644 --- a/js/tests/activate.test.ts +++ b/js/tests/activate.test.ts @@ -4,7 +4,7 @@ import { browser, progressStatus } from "../src/index"; describe("Checks activate", () => { test("Check activate", () => { - expect(browser); + expect(browser.activate); expect(progressStatus); }); }); diff --git a/js/tests/jest-setup.js b/js/tests/jest-setup.js new file mode 100644 index 00000000..20d710b8 --- /dev/null +++ b/js/tests/jest-setup.js @@ -0,0 +1,6 @@ +/* eslint-disable no-undef */ +//global.fetch = require("jest-fetch-mock"); +const version = process.version.match(/^v((\d+)\.(\d+))/).slice(2, 4).map(v => parseInt(v)); +if (version[0] === 18 && version[1] <= 16) { + globalThis.crypto = require("crypto"); +} From 30f58bb0c988adfa17e3739720e1d78b1d966337 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Mon, 7 Aug 2023 19:57:53 +0100 Subject: [PATCH 23/49] Refactor nodeinit to after attach --- js/src/treefinder.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index 141e91c2..b211a25c 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -33,6 +33,7 @@ import { // import JSZip from "jszip"; import { ArrayExt } from "@lumino/algorithm"; import { CommandRegistry } from "@lumino/commands"; +import { PromiseDelegate } from "@lumino/coreutils"; import { Message } from "@lumino/messaging"; import { PanelLayout, Widget } from "@lumino/widgets"; import { Content, ContentsModel, Format, Path, TreeFinderGridElement, TreeFinderPanelElement } from "tree-finder"; @@ -111,10 +112,9 @@ export class TreeFinderWidget extends DragDropWidget { this.rootPath = rootPath === "" ? rootPath : rootPath + ":"; this._initialLoad = true; - // CAREFUL: tree-finder currently REQUIRES the node to be added to the DOM before init can be called! - this._ready = this.nodeInit(); - void this._ready.catch(reason => showErrorMessage("Failed to init browser", reason as string)); - void this._ready.then(() => { + this._readyDelegate = new PromiseDelegate(); + void this._readyDelegate.promise.catch(reason => showErrorMessage("Failed to init browser", reason as string)); + void this._readyDelegate.promise.then(() => { // TODO: Model state of TreeFinderWidget should be updated by renamerSub process. // Currently we hard-code the refresh here, but should be moved upstream! const contentsModel = this.model!; @@ -123,7 +123,6 @@ export class TreeFinderWidget extends DragDropWidget { }); }); } - protected move(mimeData: MimeData, target: HTMLElement): DropAction { const source = mimeData.getData(TABLE_HEADER_MIME) as (keyof ContentsProxy.IJupyterContentRow); const dest = target.innerText as (keyof ContentsProxy.IJupyterContentRow); @@ -300,7 +299,7 @@ export class TreeFinderWidget extends DragDropWidget { } get ready(): Promise { - return this._ready; + return this._readyDelegate.promise; } @@ -356,6 +355,12 @@ export class TreeFinderWidget extends DragDropWidget { protected onAfterAttach(msg: Message): void { super.onAfterAttach(msg); const node = this.node; + const initPromise = this.nodeInit(); + if (this._initialLoad) { + void initPromise.then(() => { + this._readyDelegate.resolve(); + }); + } node.addEventListener("keydown", this); node.addEventListener("dragenter", this); node.addEventListener("dragover", this); @@ -566,7 +571,7 @@ export class TreeFinderWidget extends DragDropWidget { readonly translator: ITranslator; - private _ready: Promise; + private _readyDelegate: PromiseDelegate; private _trans: TranslationBundle; private _commands: CommandRegistry; private _expanding: Map; From 8e003956c9b245c1b36bdde03f8b9ae186c3a7fc Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Mon, 7 Aug 2023 19:55:52 +0100 Subject: [PATCH 24/49] Add configurable snippets Adds snippets for browser context menu. Can be configured server side, or in user settings. Allows easy access to usage of files. --- js/schema/plugin.json | 63 +++++++++++++++++++++ js/src/commands.ts | 64 +++++++++++++++++++++ js/src/snippets.ts | 122 +++++++++++++++++++++++++++++++++++++++++ jupyterfs/config.py | 15 ++++- jupyterfs/extension.py | 8 ++- jupyterfs/snippets.py | 31 +++++++++++ 6 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 js/src/snippets.ts create mode 100644 jupyterfs/snippets.py diff --git a/js/schema/plugin.json b/js/schema/plugin.json index 36904836..7a99f7e5 100644 --- a/js/schema/plugin.json +++ b/js/schema/plugin.json @@ -65,6 +65,31 @@ "default": true } } + }, + "snippet": { + "description": "Per entry snippets for how to use it, e.g. a snippet for how to open a file from a given resource", + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "description": "The designator to show to users", + "type": "string" + }, + "caption": { + "description": "An optional, longer description to show to users", + "type": "string", + "default": "" + }, + "pattern": { + "description": "A regular expression to match against the full URL of the entry, indicating if this snippet is valid for it", + "type": "string", + "default": "" + }, + "template": { + "description": "A template string to build up the snippet", + "type": "string" + } + } } }, @@ -76,6 +101,44 @@ "type": "array", "default": [] }, + "snippets": { + "title": "Snippets", + "description": "A list of usage snippets", + "items": { "$ref": "#/definitions/snippet" }, + "type": "array", + "default": [ + { + "label": "Read CSV as dataframe", + "caption": "Read the contents of this CSV file as a pandas DataFrame", + "pattern": "\\.csv$", + "template": "from fs import open_fs\nimport pandas\nwith open_fs(\"{{url}}\") as fs_instance:\n df = pandas.read_csv(fs_instance.openbin(\"{{path}}\"))" + }, + { + "label": "Read contents", + "caption": "Read the contents of this file with PyFilesystem", + "pattern": "^(?!:osfs://)(.*/)?[^/]+$", + "template": "from fs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n contents = fs_instance.readbytes(\"{{path}}\")" + }, + { + "label": "Read contents (local)", + "caption": "Read the contents of this file with PyFilesystem", + "pattern": "^(?:osfs://)(.*/)?[^/]+$", + "template": "import pathlib;\ncontents = pathlib.Path(\"{{resource}}/{{path}}\").read_bytes()" + }, + { + "label": "Read text", + "caption": "Read the contents of this file as text with PyFilesystem", + "pattern": "^(?!:osfs://)(.*/)?[^/]+$", + "template": "from fs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n contents = fs_instance.readtext(\"{{path}}\")" + }, + { + "label": "List directory contents", + "caption": "List the entries of this directory with PyFilesystem", + "pattern": "^.*/$", + "template": "from fs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n entries = fs_instance.listdir(\"{{path}}\")" + } + ] + }, "options": { "title": "Options", "description": "Global options for jupyter-fs", diff --git a/js/src/commands.ts b/js/src/commands.ts index a4aa075b..d329bea1 100644 --- a/js/src/commands.ts +++ b/js/src/commands.ts @@ -34,6 +34,7 @@ import type { TreeFinderTracker } from "./treefinder"; import { getContentParent, getRefreshTargets, revealAndSelectPath } from "./contents_utils"; import { ISettingRegistry } from "@jupyterlab/settingregistry"; import { showErrorMessage } from "@jupyterlab/apputils"; +import { getAllSnippets, instantiateSnippet, Snippet } from "./snippets"; // define the command ids as a constant tuple export const commandNames = [ @@ -100,6 +101,28 @@ function _getRelativePaths(selectedFiles: Array { + const encoded = new TextEncoder().encode(value); // encode as (utf-8) Uint8Array + const buffer = await crypto.subtle.digest("SHA-256", encoded); // hash the message + const hash = Array.from(new Uint8Array(buffer)); // convert buffer to byte array + return hash + .map(b => b.toString(16).padStart(2, "0")) + .join(""); // convert bytes to hex string +} + + +async function _commandKeyForSnippet(snippet: Snippet): Promise { + return `jupyterfs:snippet-${snippet.label}-${await _digestString(snippet.label + snippet.caption + snippet.pattern.source + snippet.template)}`; +} + + +function _normalizedUrlForSnippet(content: Content, baseUrl: string): string { + const split = content.pathstr.split("/", 2); + const path = split[split.length - 1]; + return `${baseUrl}/${path}${content.hasChildren ? "/" : ""}`; +} + + /** * Create commands that will have the same IDs indepent of settings/resources * @@ -340,11 +363,46 @@ export async function createDynamicCommands( })); } + + const snippetsMenu = new Menu({ commands: app.commands }); + snippetsMenu.title.label = "Snippets"; + const snippets = await getAllSnippets(settings); + const snippetCommands = [] as IDisposable[]; + const snippetIds = new Set(); + for (const snippet of snippets) { + const key = await _commandKeyForSnippet(snippet); + if (snippetIds.has(key)) { + console.warn("Discarding duplicate snippet", snippet); + continue; + } + snippetIds.add(key); + snippetsMenu.addItem({ command: key }); + snippetCommands.push(app.commands.addCommand(key, { + execute: async (args: unknown) => { + const sidebar = tracker.currentWidget!; + const content = sidebar.treefinder.selection![0]; + const instantiated = instantiateSnippet(snippet.template, sidebar.url, content.pathstr); + await navigator.clipboard.writeText(instantiated); + }, + label: snippet.label, + caption: snippet.caption, + isVisible: () => { + const sidebar = tracker.currentWidget; + const selection = sidebar?.treefinder.selection; + if (selection?.length) { + return snippet.pattern.test(_normalizedUrlForSnippet(selection[0], sidebar!.url)); + } + return false; + }, + })); + } + const selector = ".jp-tree-finder-sidebar"; let contextMenuRank = 1; return [ ...columnCommands, + ...snippetCommands, // context menu items app.contextMenu.addItem({ @@ -398,6 +456,12 @@ export async function createDynamicCommands( selector, rank: contextMenuRank++, }), + app.contextMenu.addItem({ + type: "submenu", + submenu: snippetsMenu, + selector, + rank: contextMenuRank++, + }), app.contextMenu.addItem({ type: "separator", selector, diff --git a/js/src/snippets.ts b/js/src/snippets.ts new file mode 100644 index 00000000..79125c43 --- /dev/null +++ b/js/src/snippets.ts @@ -0,0 +1,122 @@ +/****************************************************************************** + * + * Copyright (c) 2023, the jupyter-fs authors. + * + * This file is part of the jupyter-fs library, distributed under the terms of + * the Apache License 2.0. The full license can be found in the LICENSE file. + * + */ + +import { URLExt } from "@jupyterlab/coreutils"; +import { ServerConnection } from "@jupyterlab/services"; +import { ISettingRegistry } from "@jupyterlab/settingregistry"; + + +interface RawSnippet { + label: string; + caption: string; + pattern: string; + template: string; +} + +interface ISnippetsResponse { + snippets: RawSnippet[]; +} + +const processUrlRegex = /^(?.+?):\/\/(?:[^@]+@)?(?.*)$/; +const templateTokenFinders = {} as {[key: string]: RegExp}; + + +/** + * A usage snippet specification + */ +export interface Snippet { + /** + * The designator to show to users + */ + label: string; + + /** + * An optional, longer description to show to users + */ + caption: string; + + /** + * A regular expression to match against the full URL of the entry, indicating if this snippet is valid for it + */ + pattern: RegExp; + + /** + * A template string to build up the snippet + */ + template: string; +} + + +/** + * Get all the snippet specifications in the settings + */ +export function getSettingsSnippets(settings?: ISettingRegistry.ISettings): Snippet[] { + const raw = (settings?.composite.snippets ?? []) as any as RawSnippet[]; + return raw.map(s => ({ ...s, pattern: new RegExp(s.pattern) })); +} + +/** + * Sends a GET request to obtain all existing profile names in Neptune + */ +export async function getServerSnippets(settings?: ServerConnection.ISettings): Promise { + if (!settings) { + settings = ServerConnection.makeSettings(); + } + return ServerConnection.makeRequest( + URLExt.join(settings.baseUrl, "/jupyterfs/snippets"), + { method: "GET" }, + settings + ).then(response => { + if (!response.ok) { + return Promise.reject(response); + } + return response.json() as Promise; + }).then(data => data.snippets.map(s => ({ ...s, pattern: new RegExp(s.pattern) }))); +} + +/** + * Get the snippet specifications from all sources + */ +export async function getAllSnippets(settings?: ISettingRegistry.ISettings): Promise { + return (await getServerSnippets()).concat(getSettingsSnippets(settings)); +} + + +/** + * Instantiate the template of a snippet. + * + * @param template The template to instantiate + * @param resource The resource the entry belongs to + * @param path The local path of the entry + */ +export function instantiateSnippet(template: string, url: string, pathstr: string) { + const parsed = processUrlRegex.exec(url); + // eslint-disable-next-line prefer-const + const splitloc = pathstr.indexOf("/"); + const drive = splitloc !== -1 ? pathstr.slice(0, splitloc) : pathstr; + let relativePath = splitloc !== -1 ? pathstr.slice(splitloc + 1) : ""; + relativePath = relativePath.replace(/^\//g, ""); // trim all leading "/" + const args = { + ...parsed?.groups, + url, + path: relativePath, + full_url: `${url.replace(/\/$/, "")}/${relativePath}`, + full_path: `${drive}:/${relativePath}`, + drive, + }; + + let templated = template; + for (const key of Object.keys(args) as Array) { + if (!(key in templateTokenFinders)) { + templateTokenFinders[key] = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g"); + } + templated = templated.replace(templateTokenFinders[key], args[key]); + } + return templated; +} diff --git a/jupyterfs/config.py b/jupyterfs/config.py index 83e406a0..6b8ce8f5 100644 --- a/jupyterfs/config.py +++ b/jupyterfs/config.py @@ -8,7 +8,7 @@ from jupyter_server.services.contents.largefilemanager import LargeFileManager from jupyter_server.services.contents.manager import ContentsManager from jupyter_server.transutils import _i18n -from traitlets import Bool, List, Type, Unicode +from traitlets import Bool, Dict, List, Type, Unicode from traitlets.config import Configurable __all__ = ["JupyterFs"] @@ -45,3 +45,16 @@ class JupyterFs(Configurable): "regular expressions to match against resource URLs. At least one must match" ), ) + + snippets = List( + config=True, + per_key_traits=Dict({ + "label": Unicode(help="The designator to show to users"), + "caption": Unicode("", help="An optional, longer description to show to users"), + "pattern": Unicode("", help="A regular expression to match against the full URL of the entry, indicating if this snippet is valid for it"), + "template": Unicode(help="A template string to build up the snippet"), + }), + help=_i18n( + "per entry snippets for how to use it, e.g. a snippet for how to open a file from a given resource" + ), + ) \ No newline at end of file diff --git a/jupyterfs/extension.py b/jupyterfs/extension.py index e84f4724..8ca84695 100644 --- a/jupyterfs/extension.py +++ b/jupyterfs/extension.py @@ -5,14 +5,13 @@ # This file is part of the jupyter-fs library, distributed under the terms of # the Apache License 2.0. The full license can be found in the LICENSE file. # -from __future__ import print_function - import warnings from jupyter_server.utils import url_path_join from ._version import __version__ # noqa: F401 from .metamanager import MetaManager, MetaManagerHandler +from .snippets import SnippetsHandler _mm_config_warning_msg = """Misconfiguration of MetaManager. Please add: @@ -56,5 +55,8 @@ def _load_jupyter_server_extension(serverapp): % url_path_join(base_url, resources_url) ) web_app.add_handlers( - host_pattern, [(url_path_join(base_url, resources_url), MetaManagerHandler)] + host_pattern, [ + (url_path_join(base_url, resources_url), MetaManagerHandler), + (url_path_join(base_url, "jupyterfs/snippets"), SnippetsHandler), + ] ) diff --git a/jupyterfs/snippets.py b/jupyterfs/snippets.py new file mode 100644 index 00000000..f568bd65 --- /dev/null +++ b/jupyterfs/snippets.py @@ -0,0 +1,31 @@ +# ***************************************************************************** +# +# Copyright (c) 2023, the jupyter-fs authors. +# +# This file is part of the jupyter-fs library, distributed under the terms of +# the Apache License 2.0. The full license can be found in the LICENSE file. +# + +from jupyter_server.base.handlers import APIHandler +from tornado import web + +from .config import JupyterFs as JupyterFsConfig + +class SnippetsHandler(APIHandler): + _jupyterfsConfig = None + + @property + def fsconfig(self): + # TODO: This pattern will not pick up changes to config after this! + if self._jupyterfsConfig is None: + self._jupyterfsConfig = JupyterFsConfig(config=self.config) + + return self._jupyterfsConfig + + @web.authenticated + def get(self): + """Get the server-side configured snippets""" + print(self.fsconfig.snippets) + self.write({ + "snippets": self.fsconfig.snippets + }) From 1aa7fce8f7a9206824f697095f695afd60d0eba2 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 10 Aug 2023 13:56:03 +0100 Subject: [PATCH 25/49] Add settings editor for template Have the template field use a textarea input. Some tricks are needed since the uiSchema is not fully exposed from lab. --- js/src/index.tsx | 11 ++++++++++- js/src/{snippets.ts => snippets.tsx} | 24 ++++++++++++++++++++++++ js/style/index.css | 1 + js/style/snippets.css | 8 ++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) rename js/src/{snippets.ts => snippets.tsx} (82%) create mode 100644 js/style/snippets.css diff --git a/js/src/index.tsx b/js/src/index.tsx index b1a181ed..41b6b906 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -13,7 +13,7 @@ import { IDocumentManager } from "@jupyterlab/docmanager"; import { ISettingRegistry } from "@jupyterlab/settingregistry"; import { IStatusBar } from "@jupyterlab/statusbar"; import { ITranslator } from "@jupyterlab/translation"; -import { folderIcon, fileIcon } from "@jupyterlab/ui-components"; +import { folderIcon, fileIcon, IFormComponentRegistry } from "@jupyterlab/ui-components"; import { DisposableSet, IDisposable } from "@lumino/disposable"; import * as React from "react"; import * as ReactDOM from "react-dom"; @@ -23,6 +23,7 @@ import { commandIDs, createDynamicCommands, createStaticCommands, idFromResource import { ContentsProxy } from "./contents_proxy"; import { FSComm, IFSOptions, IFSResource } from "./filesystem"; import { FileUploadStatus } from "./progress"; +import { snippetFormRender } from "./snippets"; import { TreeFinderSidebar } from "./treefinder"; import { ITreeFinderMain } from "./tokens"; @@ -41,6 +42,7 @@ export const browser: JupyterFrontEndPlugin = { ISettingRegistry, IThemeManager, ], + optional: [IFormComponentRegistry], provides: ITreeFinderMain, async activate( @@ -52,6 +54,7 @@ export const browser: JupyterFrontEndPlugin = { router: IRouter, settingRegistry: ISettingRegistry, themeManager: IThemeManager, + editorRegistry: IFormComponentRegistry | null ): Promise { const comm = new FSComm(); const widgetMap : {[key: string]: TreeFinderSidebar} = {}; @@ -66,6 +69,12 @@ export const browser: JupyterFrontEndPlugin = { console.warn(`Failed to load settings for the jupyter-fs extension.\n${error}`); } + if (editorRegistry) { + editorRegistry.addRenderer("snippets", snippetFormRender); + // Format for lab 4.x + + // editorRegistry.addRenderer(`${BROWSER_ID}:snippets`, snippetFormRender); + } + let columns = settings?.composite.display_columns as Array ?? ["size"]; const sharedSidebarProps: Omit = { diff --git a/js/src/snippets.ts b/js/src/snippets.tsx similarity index 82% rename from js/src/snippets.ts rename to js/src/snippets.tsx index 79125c43..8308a7df 100644 --- a/js/src/snippets.ts +++ b/js/src/snippets.tsx @@ -10,6 +10,30 @@ import { URLExt } from "@jupyterlab/coreutils"; import { ServerConnection } from "@jupyterlab/services"; import { ISettingRegistry } from "@jupyterlab/settingregistry"; +import * as React from "react"; + +// for lab 4, import this from @rjsf/utils: +import type { FieldProps } from "@rjsf/core"; + +function _mknode(obj: any, paths: string[]) { + for (const path of paths) { + obj = obj[path] = obj[path] ?? {}; + } + return obj; +} + +/** + * Trick to set uiSchema on our settings editor form elements. + * + * We use it to set the "template" to a "textarea" multiline input + */ +export function snippetFormRender(props: FieldProps) { + const ArrayField = props.registry.fields.ArrayField; + const uiSchema = { ...props.uiSchema }; + const templateUiSchema = _mknode(uiSchema, ["items", "template"]); + templateUiSchema["ui:widget"] = "textarea"; + return ; +} interface RawSnippet { diff --git a/js/style/index.css b/js/style/index.css index 9a959686..e6d51721 100644 --- a/js/style/index.css +++ b/js/style/index.css @@ -16,4 +16,5 @@ @import url('./auth.css'); @import url('./filetree.css'); +@import url('./snippets.css'); @import url('./treefinder.css'); diff --git a/js/style/snippets.css b/js/style/snippets.css new file mode 100644 index 00000000..da2b3fca --- /dev/null +++ b/js/style/snippets.css @@ -0,0 +1,8 @@ + +/** + * Remove this is lab ever starts styling textareas itself: + */ +.jp-SettingsPanel textarea.form-control { + min-height: 3lh; /* minimum height of 3 lines */ + width: 80ch; /* initial width of 80 characters */ +} From f8d129d86cd67f9c193851af11f81493130dbaa2 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 10 Aug 2023 13:42:25 +0100 Subject: [PATCH 26/49] Add tests for snippets --- js/tests/snippets.test.ts | 177 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 js/tests/snippets.test.ts diff --git a/js/tests/snippets.test.ts b/js/tests/snippets.test.ts new file mode 100644 index 00000000..23154da2 --- /dev/null +++ b/js/tests/snippets.test.ts @@ -0,0 +1,177 @@ +import "isomorphic-fetch"; + +import { + ISettingRegistry, + SettingRegistry, +} from "@jupyterlab/settingregistry"; +import { StateDB } from "@jupyterlab/statedb"; +import { getServerSnippets, getSettingsSnippets, instantiateSnippet, snippetFormRender } from "../src/snippets"; + +import * as importedSchema from "../schema/plugin.json"; +import { ServerConnection } from "@jupyterlab/services"; + +describe("instantiateSnippet", () => { + const knownArgs = [ + "url", + "path", + "full_url", + "full_path", + "drive", + "protocol", + "resource", + ]; + + it("should replace known parameters", () => { + const pathstr = "drivename/full/path/file.txt"; + const url = "scheme://_:{{creds}}@url/path"; + + const template = `{\n ${knownArgs.map(arg => `"${arg}": "{{${arg}}}"`).join(",\n ")}\n}`; + + const snippet = instantiateSnippet(template, url, pathstr); + expect(snippet).toEqual(`{ + "url": "scheme://_:{{creds}}@url/path", + "path": "full/path/file.txt", + "full_url": "scheme://_:{{creds}}@url/path/full/path/file.txt", + "full_path": "drivename:/full/path/file.txt", + "drive": "drivename", + "protocol": "scheme", + "resource": "url/path" +}`); + }); + + it("should handle root path", () => { + const pathstr = "drivename"; + const url = "scheme://"; + + const template = `{\n ${knownArgs.map(arg => `"${arg}": "{{${arg}}}"`).join(",\n ")}\n}`; + + const snippet = instantiateSnippet(template, url, pathstr); + expect(snippet).toEqual(`{ + "url": "scheme://", + "path": "", + "full_url": "scheme://", + "full_path": "drivename:/", + "drive": "drivename", + "protocol": "scheme", + "resource": "" +}`); + }); + +}); + + +class TestConnector extends StateDB { + schemas: { [key: string]: ISettingRegistry.ISchema } = { + "jupyter-fs": importedSchema as ISettingRegistry.ISchema, + }; + + async fetch(id: string): Promise { + const fetched = await super.fetch(id); + if (!fetched && !this.schemas[id]) { + return undefined; + } + + const schema = importedSchema as ISettingRegistry.ISchema; + const composite = {}; + const user = {}; + const raw = (fetched as string) || "{ }"; + const version = "test"; + return { id, data: { composite, user }, raw, schema, version }; + } + + async list(): Promise { + return Promise.reject("list method not implemented"); + } +} + + +describe("getSettingsSnippets", () => { + + const connector = new TestConnector(); + const timeout = 500; + let registry: SettingRegistry; + + + afterEach(() => connector.clear()); + + beforeEach(() => { + registry = new SettingRegistry({ connector, timeout }); + }); + + + it("should return snippets with regexp instances", async () => { + const settings = await registry.load("jupyter-fs"); + const snippets = getSettingsSnippets(settings); + expect(snippets.map(s => s.label)).toEqual(importedSchema.properties.snippets.default.map(d => d.label)); + expect(snippets.map(s => s.caption)).toEqual(importedSchema.properties.snippets.default.map(d => d.caption)); + expect(snippets.map(s => s.template)).toEqual(importedSchema.properties.snippets.default.map(d => d.template)); + const patterns = snippets.map(s => s.pattern); + for (const p of patterns) { + expect(p).toBeInstanceOf(RegExp); + } + }); + + + it("should handle missing settings", () => { + const snippets = getSettingsSnippets(); + expect(snippets).toEqual([]); + }); + +}); + + +describe("getServerSnippets", () => { + + + it("should return snippets with regexp instances", async () => { + const originalSnippets = importedSchema.properties.snippets.default; + const settings = ServerConnection.makeSettings({ + fetch: async (input: RequestInfo, init?: RequestInit): Promise => new Response(JSON.stringify({ snippets: originalSnippets })), + }); + const snippets = await getServerSnippets(settings); + expect(snippets.map(s => s.label)).toEqual(importedSchema.properties.snippets.default.map(d => d.label)); + expect(snippets.map(s => s.caption)).toEqual(importedSchema.properties.snippets.default.map(d => d.caption)); + expect(snippets.map(s => s.template)).toEqual(importedSchema.properties.snippets.default.map(d => d.template)); + const patterns = snippets.map(s => s.pattern); + for (const p of patterns) { + expect(p).toBeInstanceOf(RegExp); + } + }); + + + it("should handle network errors", async () => { + const settings = ServerConnection.makeSettings({ + fetch: async (input: RequestInfo, init?: RequestInit): Promise => new Response(null, { status: 500 }), + }); + const snippetsPromise = getServerSnippets(settings); + await expect(snippetsPromise).rejects.toBeInstanceOf(Response); + }); + +}); + +describe("snippetFormRender", () => { + + it("should populate the uiSchema", () => { + function mockField(props: any): any { + // no-op + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const comp = snippetFormRender({ + registry: { + fields: { + ArrayField: mockField, + }, + } as any, + uiSchema: {}, + } as any); + + expect(comp.props.uiSchema).toEqual({ + items: { + template: { + "ui:widget": "textarea", + }, + }, + }); + }); + +}); From 0566f80f582f6e4212eae556580dd9537523c095 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Fri, 11 Aug 2023 14:25:10 +0100 Subject: [PATCH 27/49] Fix string split miss + clenaup I confused string split of Python and JS: JS does not include remainder. --- js/src/commands.ts | 5 ++--- js/src/contents_utils.ts | 12 ++++++++++++ js/src/snippets.tsx | 8 +++----- jupyterfs/snippets.py | 1 - 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/js/src/commands.ts b/js/src/commands.ts index d329bea1..879a8799 100644 --- a/js/src/commands.ts +++ b/js/src/commands.ts @@ -31,7 +31,7 @@ import { TreeFinderSidebar } from "./treefinder"; import type { IFSResource } from "./filesystem"; import type { ContentsProxy } from "./contents_proxy"; import type { TreeFinderTracker } from "./treefinder"; -import { getContentParent, getRefreshTargets, revealAndSelectPath } from "./contents_utils"; +import { getContentParent, getRefreshTargets, revealAndSelectPath, splitPathstrDrive } from "./contents_utils"; import { ISettingRegistry } from "@jupyterlab/settingregistry"; import { showErrorMessage } from "@jupyterlab/apputils"; import { getAllSnippets, instantiateSnippet, Snippet } from "./snippets"; @@ -117,8 +117,7 @@ async function _commandKeyForSnippet(snippet: Snippet): Promise { function _normalizedUrlForSnippet(content: Content, baseUrl: string): string { - const split = content.pathstr.split("/", 2); - const path = split[split.length - 1]; + const path = splitPathstrDrive(content.pathstr)[1]; return `${baseUrl}/${path}${content.hasChildren ? "/" : ""}`; } diff --git a/js/src/contents_utils.ts b/js/src/contents_utils.ts index 1506095b..5c8314bd 100644 --- a/js/src/contents_utils.ts +++ b/js/src/contents_utils.ts @@ -137,3 +137,15 @@ export function getRefreshTargets( } return invalidateTargets; } + + +/** + * Split a "pathstr" into its drive and path components + */ +export function splitPathstrDrive(pathstr: string): [string, string] { + const splitloc = pathstr.indexOf("/"); + if (splitloc === -1) { + return [pathstr, ""]; + } + return [pathstr.slice(0, splitloc), pathstr.slice(splitloc + 1).replace(/^\//g, "")]; +} diff --git a/js/src/snippets.tsx b/js/src/snippets.tsx index 8308a7df..ab32ad64 100644 --- a/js/src/snippets.tsx +++ b/js/src/snippets.tsx @@ -14,6 +14,7 @@ import * as React from "react"; // for lab 4, import this from @rjsf/utils: import type { FieldProps } from "@rjsf/core"; +import { splitPathstrDrive } from "./contents_utils"; function _mknode(obj: any, paths: string[]) { for (const path of paths) { @@ -86,7 +87,7 @@ export function getSettingsSnippets(settings?: ISettingRegistry.ISettings): Snip } /** - * Sends a GET request to obtain all existing profile names in Neptune + * Gets all the snippet specifications configured on the server */ export async function getServerSnippets(settings?: ServerConnection.ISettings): Promise { if (!settings) { @@ -122,10 +123,7 @@ export async function getAllSnippets(settings?: ISettingRegistry.ISettings): Pro export function instantiateSnippet(template: string, url: string, pathstr: string) { const parsed = processUrlRegex.exec(url); // eslint-disable-next-line prefer-const - const splitloc = pathstr.indexOf("/"); - const drive = splitloc !== -1 ? pathstr.slice(0, splitloc) : pathstr; - let relativePath = splitloc !== -1 ? pathstr.slice(splitloc + 1) : ""; - relativePath = relativePath.replace(/^\//g, ""); // trim all leading "/" + const [drive, relativePath] = splitPathstrDrive(pathstr); const args = { ...parsed?.groups, url, diff --git a/jupyterfs/snippets.py b/jupyterfs/snippets.py index f568bd65..9e4ab435 100644 --- a/jupyterfs/snippets.py +++ b/jupyterfs/snippets.py @@ -25,7 +25,6 @@ def fsconfig(self): @web.authenticated def get(self): """Get the server-side configured snippets""" - print(self.fsconfig.snippets) self.write({ "snippets": self.fsconfig.snippets }) From e859f7b1b1bbb413af742cb2979b8984c6705c71 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Mon, 14 Aug 2023 12:03:24 +0100 Subject: [PATCH 28/49] add regex comments --- js/src/contents_utils.ts | 3 ++- js/src/snippets.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/js/src/contents_utils.ts b/js/src/contents_utils.ts index 5c8314bd..a4bc94f7 100644 --- a/js/src/contents_utils.ts +++ b/js/src/contents_utils.ts @@ -147,5 +147,6 @@ export function splitPathstrDrive(pathstr: string): [string, string] { if (splitloc === -1) { return [pathstr, ""]; } - return [pathstr.slice(0, splitloc), pathstr.slice(splitloc + 1).replace(/^\//g, "")]; + // split, and trim leading forward slashes on the path component + return [pathstr.slice(0, splitloc), pathstr.slice(splitloc + 1).replace(/^[/]*/, "")]; } diff --git a/js/src/snippets.tsx b/js/src/snippets.tsx index ab32ad64..47bf9188 100644 --- a/js/src/snippets.tsx +++ b/js/src/snippets.tsx @@ -136,6 +136,7 @@ export function instantiateSnippet(template: string, url: string, pathstr: strin let templated = template; for (const key of Object.keys(args) as Array) { if (!(key in templateTokenFinders)) { + // match `key` wrapped in double curly-braces (and optionally whitespace padding within the braces) templateTokenFinders[key] = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g"); } templated = templated.replace(templateTokenFinders[key], args[key]); From 91ff5882105a3d7453c343e06f2e21c0c9ebfa1b Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 17 Aug 2023 18:09:32 +0100 Subject: [PATCH 29/49] fix init on auth dismiss Ensure we can still do a partial init even if the initial auth dialog is dismissed. --- js/src/index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/js/src/index.tsx b/js/src/index.tsx index 41b6b906..0dfeb1f3 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -156,12 +156,19 @@ export const browser: JupyterFrontEndPlugin = { const dialogElem = document.createElement("div"); document.body.appendChild(dialogElem); - const handleClose = () => { + let submitted = false; + const handleClose = async () => { ReactDOM.unmountComponentAtNode(dialogElem); dialogElem.remove(); + if (!submitted) { + // if prompt cancelled, refresh all inited resources + cleanup(); + await refreshWidgets({ resources: resources.filter(r => r.init), options }); + } }; const handleSubmit = async (values: {[url: string]: {[key: string]: string}}) => { + submitted = true; resources = await comm.initResourceRequest({ resources: resources.map(r => ({ ...r, tokenDict: values[r.url] })), options, From 3bc9d04b5c7be7d0655f838135139f7e85751cdd Mon Sep 17 00:00:00 2001 From: Dave Malvin Limanda <47667667+davemalvin@users.noreply.github.com> Date: Fri, 26 May 2023 13:52:44 +0100 Subject: [PATCH 30/49] Restore last opened directory --- js/src/commands.ts | 21 ++++++- js/src/index.tsx | 30 +++++++-- js/src/treefinder.ts | 146 +++++++++++++++++++------------------------ 3 files changed, 110 insertions(+), 87 deletions(-) diff --git a/js/src/commands.ts b/js/src/commands.ts index 879a8799..0ad7ed54 100644 --- a/js/src/commands.ts +++ b/js/src/commands.ts @@ -31,7 +31,7 @@ import { TreeFinderSidebar } from "./treefinder"; import type { IFSResource } from "./filesystem"; import type { ContentsProxy } from "./contents_proxy"; import type { TreeFinderTracker } from "./treefinder"; -import { getContentParent, getRefreshTargets, revealAndSelectPath, splitPathstrDrive } from "./contents_utils"; +import { getContentParent, getRefreshTargets, openDirRecursive, revealAndSelectPath, splitPathstrDrive } from "./contents_utils"; import { ISettingRegistry } from "@jupyterlab/settingregistry"; import { showErrorMessage } from "@jupyterlab/apputils"; import { getAllSnippets, instantiateSnippet, Snippet } from "./snippets"; @@ -51,6 +51,7 @@ export const commandNames = [ // "navigate", "copyFullPath", "copyRelativePath", + "restore", "toggleColumnPath", "toggleColumn", ] as const; @@ -325,6 +326,24 @@ export function createStaticCommands( isEnabled: () => false, isToggled: () => true, }), + app.commands.addCommand(commandIDs.restore, { + execute: async args => { + const rootPath = args.rootPath as string; + const dirsToOpen = rootPath.split("/"); + const sidebar = tracker.findByDrive(args.id as string); + if (!sidebar) { + throw new Error(`Could not restore JupyterFS browser: ${args.id}`); + } + const treefinderwidget = sidebar.treefinder; + const model = treefinderwidget.model!; + + // If preferredDir not specified, proceed with the restore + if (!sidebar.preferredDir) { + await openDirRecursive(model, dirsToOpen); + await tracker.save(sidebar); + } + }, + }), ].reduce((set: DisposableSet, d) => { set.add(d); return set; }, new DisposableSet()); diff --git a/js/src/index.tsx b/js/src/index.tsx index 0dfeb1f3..3f115248 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -197,8 +197,17 @@ export const browser: JupyterFrontEndPlugin = { } } - // initial setup when DOM attachment of custom elements is complete. - void app.started.then(refresh); + // when ready, restore using command + const refreshed = refresh(); + void restorer.restore(TreeFinderSidebar.tracker, { + command: commandIDs.restore, + args: widget => ({ + id: widget.id, + rootPath: widget.treefinder.model?.root.pathstr, + }), + name: widget => widget.id, + when: refreshed, + }); if (settings) { // rerun setup whenever relevant settings change @@ -222,6 +231,7 @@ export const browser: JupyterFrontEndPlugin = { `; } + let initialThemeLoad = true; themeManager.themeChanged.connect(() => { // Update SVG icon fills (since we put them in pseudo-elements we cannot style with CSS) const primary = getComputedStyle(document.documentElement).getPropertyValue("--jp-ui-font-color1"); @@ -231,9 +241,19 @@ export const browser: JupyterFrontEndPlugin = { ); // Refresh widgets in case font/border sizes etc have changed - void Promise.all(Object.keys(widgetMap).map( - key => widgetMap[key].treefinder.nodeInit() - )); + if (initialThemeLoad) { + initialThemeLoad = false; + void app.restored.then(() => { + // offset it by a timeout to ensure we clear the initial async stack + setTimeout(() => void Object.keys(widgetMap).map( + key => widgetMap[key].treefinder.nodeInit() + ), 0); + }); + } else { + Object.keys(widgetMap).map( + key => widgetMap[key].treefinder.nodeInit() + ); + } }); style.textContent = iconStyleContent(folderIcon.svgstr, fileIcon.svgstr); diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index b211a25c..5cd665ee 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -199,8 +199,9 @@ export class TreeFinderWidget extends DragDropWidget { } async nodeInit() { - // The contents of root passed to node.init is not (currently) considered, so do not ask for it.. - await this.contentsProxy.get(this.rootPath, { content: false }).then(root => this.node.init({ + // The contents of root passed to node.init is not (currently) considered, so do not ask for it. + const root = await this.contentsProxy.get(this.rootPath, { content: false }); + await this.node.init({ root, gridOptions: { columnFormatters: { @@ -214,71 +215,74 @@ export class TreeFinderWidget extends DragDropWidget { modelOptions: { columnNames: this.columns, }, - })).then(() => { - const grid = this.node.querySelector>("tree-finder-grid"); - grid?.addStyleListener(() => { - // Set root-level load indicator - grid.classList.toggle("jfs-mod-loading", this._initialLoad); - - // Fix corner cleanup (workaround for underlying bug where we end up with two resize handles) - const resizeSpans = grid.querySelectorAll(`thead tr > th:first-child > span.rt-column-resize`); - const nHeaderRows = grid.querySelectorAll("thead tr").length; - if (resizeSpans.length > nHeaderRows) { - // something went wrong, and we ended up with double resize handles. Clear the classes from the first one: - for (const span of grid.querySelectorAll(`thead tr > th:first-child > span.rt-column-resize:first-child`)) { - span.removeAttribute("class"); - } + }); + + const grid = this.node.querySelector>("tree-finder-grid"); + grid?.addStyleListener(() => { + // Set root-level load indicator + grid.classList.toggle("jfs-mod-loading", this._initialLoad); + + // Fix corner cleanup (workaround for underlying bug where we end up with two resize handles) + const resizeSpans = grid.querySelectorAll(`thead tr > th:first-child > span.rt-column-resize`); + const nHeaderRows = grid.querySelectorAll("thead tr").length; + if (resizeSpans.length > nHeaderRows) { + // something went wrong, and we ended up with double resize handles. Clear the classes from the first one: + for (const span of grid.querySelectorAll(`thead tr > th:first-child > span.rt-column-resize:first-child`)) { + span.removeAttribute("class"); } + } - // Fix focus and tabbing - let lastSelectIdx = this.model?.selectedLast ? this.model?.contents.indexOf(this.model.selectedLast) : -1; - const lostFocus = document.activeElement === document.body; - for (const rowHeader of grid.querySelectorAll("tr > th")) { - const tableHeader = rowHeader.querySelector("span.tf-header-name"); + // Fix focus and tabbing + let lastSelectIdx = this.model?.selectedLast ? this.model?.contents.indexOf(this.model.selectedLast) : -1; + const lostFocus = document.activeElement === document.body; + for (const rowHeader of grid.querySelectorAll("tr > th")) { + const tableHeader = rowHeader.querySelector("span.tf-header-name"); - if (tableHeader) { - // If tableheader is path, do not make it draggable - if (tableHeader.innerText !== "path") { - tableHeader.classList.add(this.dragHandleClass); - } + if (tableHeader) { + // If tableheader is path, do not make it draggable + if (tableHeader.innerText !== "path") { + tableHeader.classList.add(this.dragHandleClass); } + } - const nameElement = rowHeader.querySelector("span.rt-group-name"); - // Ensure we can tab to all items - nameElement?.setAttribute("tabindex", "0"); - // Ensure last selected element retains focus after redraw: - if (lostFocus && nameElement && lastSelectIdx !== -1) { - const meta = grid.getMeta(rowHeader); - if (meta && meta.y === lastSelectIdx) { - nameElement.focus(); - lastSelectIdx = -1; - } + const nameElement = rowHeader.querySelector("span.rt-group-name"); + // Ensure we can tab to all items + nameElement?.setAttribute("tabindex", "0"); + // Ensure last selected element retains focus after redraw: + if (lostFocus && nameElement && lastSelectIdx !== -1) { + const meta = grid.getMeta(rowHeader); + if (meta && meta.y === lastSelectIdx) { + nameElement.focus(); + lastSelectIdx = -1; } + } - // Add "loading" indicator for folders that are fetching children - if (nameElement) { - const meta = grid.getMeta(rowHeader); - const content = meta?.y ? this.model?.contents[meta.y] : undefined; - if (content) { - rowHeader.classList.toggle("jfs-mod-loading", !!nameElement && (this._expanding.get(content.pathstr) || 0) > 0); - } + // Add "loading" indicator for folders that are fetching children + if (nameElement) { + const meta = grid.getMeta(rowHeader); + const content = meta?.y ? this.model?.contents[meta.y] : undefined; + if (content) { + rowHeader.classList.toggle("jfs-mod-loading", !!nameElement && (this._expanding.get(content.pathstr) || 0) > 0); } } + } + }); + if (this.uploader) { + this.uploader.model = this.model!; + } else { + this.uploader = new Uploader({ + contentsProxy: this.contentsProxy, + model: this.model!, }); - if (this.uploader) { - this.uploader.model = this.model!; + } + this.model!.openSub.subscribe(rows => rows.forEach(row => { + if (!row.getChildren) { + void this._commands.execute("docmanager:open", { path: Path.fromarray(row.path) }); } else { - this.uploader = new Uploader({ - contentsProxy: this.contentsProxy, - model: this.model!, - }); + const widget = TreeFinderSidebar.tracker.findByDrive(this.parent!.id)!; + void TreeFinderSidebar.tracker.save(widget); } - this.model!.openSub.subscribe(rows => rows.forEach(row => { - if (!row.getChildren) { - void this._commands.execute("docmanager:open", { path: Path.fromarray(row.path) }); - } - })); - }); + })); } get columns(): Array { @@ -598,6 +602,7 @@ export class TreeFinderSidebar extends Widget { caption = "TreeFinder", id = "jupyterlab-tree-finder", settings, + preferredDir, }: TreeFinderSidebar.IOptions) { super(); this.id = id; @@ -609,6 +614,7 @@ export class TreeFinderSidebar extends Widget { this.toolbar = new Toolbar(); this.toolbar.addClass("jp-tree-finder-toolbar"); + this.preferredDir = preferredDir; this.treefinder = new TreeFinderWidget({ app, rootPath, columns, settings }); @@ -619,26 +625,6 @@ export class TreeFinderSidebar extends Widget { restore() { // restore expansion prior to rebuild void this.treefinder.ready.then(() => this.treefinder.refresh()); - // const array: Array> = []; - // Object.keys(this.controller).forEach(key => { - // if (this.controller[key].open && (key !== "")) { - // const promise = this.cm.get(this.basepath + key); - // promise.catch(res => { - // // eslint-disable-next-line no-console - // console.log(res); - // }); - // array.push(promise); - // } - // }); - // Promise.all(array).then(results => { - // for (const r in results) { - // const row_element = this.node.querySelector("[id='" + u_btoa(results[r].path.replace(this.basepath, "")) + "']"); - // this.buildTableContents(results[r].content, 1 + results[r].path.split("/").length, row_element); - // } - // }).catch(reasons => { - // // eslint-disable-next-line no-console - // console.log(reasons); - // }); } async download(path: string, folder: boolean): Promise { @@ -683,6 +669,7 @@ export class TreeFinderSidebar extends Widget { this.treefinder.draw(); } + preferredDir: string | undefined; toolbar: Toolbar; treefinder: TreeFinderWidget; @@ -691,7 +678,7 @@ export class TreeFinderSidebar extends Widget { // eslint-disable-next-line @typescript-eslint/no-namespace export namespace TreeFinderSidebar { - const namespace = "jupyter-fs:TreeFinder"; + const namespace = "jupyter-fs-treefinder"; export const tracker = new TreeFinderTracker({ namespace }); export const clipboard = new JupyterClipboard(tracker); @@ -700,13 +687,12 @@ export namespace TreeFinderSidebar { app: JupyterFrontEnd; columns: Array; url: string; - - preferredDir?: string; rootPath?: string; caption?: string; id?: string; translator?: ITranslator; settings?: ISettingRegistry.ISettings; + preferredDir?: string; } export interface ISidebarProps extends IOptions { @@ -715,7 +701,6 @@ export namespace TreeFinderSidebar { resolver: IWindowResolver; restorer: ILayoutRestorer; router: IRouter; - side?: string; settings?: ISettingRegistry.ISettings; } @@ -748,9 +733,8 @@ export namespace TreeFinderSidebar { id = "jupyterlab-tree-finder", side = "left", }: TreeFinderSidebar.ISidebarProps): TreeFinderSidebar { - const widget = new TreeFinderSidebar({ app, rootPath, columns, caption, id, url, settings }); + const widget = new TreeFinderSidebar({ app, rootPath, columns, caption, id, url, settings, preferredDir }); void widget.treefinder.ready.then(() => tracker.add(widget)); - restorer.add(widget, widget.id); app.shell.add(widget, side); const new_file_button = new ToolbarButton({ From 723174a3223d70ca21a1e5321a0eb448b3ecc6d6 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Fri, 18 Aug 2023 16:23:13 +0100 Subject: [PATCH 31/49] keep folder after re-init --- js/src/treefinder.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index 5cd665ee..d49ac1d6 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -201,6 +201,7 @@ export class TreeFinderWidget extends DragDropWidget { async nodeInit() { // The contents of root passed to node.init is not (currently) considered, so do not ask for it. const root = await this.contentsProxy.get(this.rootPath, { content: false }); + this._currentFolder = this.model?.root.pathstr; await this.node.init({ root, gridOptions: { @@ -275,6 +276,9 @@ export class TreeFinderWidget extends DragDropWidget { model: this.model!, }); } + if (this._currentFolder) { + await openDirRecursive(this.model!, this._currentFolder.split("/")); + } this.model!.openSub.subscribe(rows => rows.forEach(row => { if (!row.getChildren) { void this._commands.execute("docmanager:open", { path: Path.fromarray(row.path) }); @@ -580,6 +584,7 @@ export class TreeFinderWidget extends DragDropWidget { private _commands: CommandRegistry; private _expanding: Map; private _initialLoad: boolean; + private _currentFolder: string | undefined; } export namespace TreeFinderWidget { From 126562c59c99e44b8fe066bf9c6b736d533b686f Mon Sep 17 00:00:00 2001 From: Jacob Frazer <64307592+jacob-frazer@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:50:41 +0100 Subject: [PATCH 32/49] Auth prompts, force defaults as composite wasnt including changes --- js/schema/plugin.json | 8 ++++---- jupyterfs/__init__.py | 9 +++++++++ jupyterfs/auth.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/js/schema/plugin.json b/js/schema/plugin.json index 7a99f7e5..58c263e0 100644 --- a/js/schema/plugin.json +++ b/js/schema/plugin.json @@ -111,13 +111,13 @@ "label": "Read CSV as dataframe", "caption": "Read the contents of this CSV file as a pandas DataFrame", "pattern": "\\.csv$", - "template": "from fs import open_fs\nimport pandas\nwith open_fs(\"{{url}}\") as fs_instance:\n df = pandas.read_csv(fs_instance.openbin(\"{{path}}\"))" + "template": "from jupyterfs import open_fs\nimport pandas\nwith open_fs(\"{{url}}\") as fs_instance:\n df = pandas.read_csv(fs_instance.openbin(\"{{path}}\"))" }, { "label": "Read contents", "caption": "Read the contents of this file with PyFilesystem", "pattern": "^(?!:osfs://)(.*/)?[^/]+$", - "template": "from fs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n contents = fs_instance.readbytes(\"{{path}}\")" + "template": "from jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n contents = fs_instance.readbytes(\"{{path}}\")" }, { "label": "Read contents (local)", @@ -129,13 +129,13 @@ "label": "Read text", "caption": "Read the contents of this file as text with PyFilesystem", "pattern": "^(?!:osfs://)(.*/)?[^/]+$", - "template": "from fs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n contents = fs_instance.readtext(\"{{path}}\")" + "template": "from jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n contents = fs_instance.readtext(\"{{path}}\")" }, { "label": "List directory contents", "caption": "List the entries of this directory with PyFilesystem", "pattern": "^.*/$", - "template": "from fs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n entries = fs_instance.listdir(\"{{path}}\")" + "template": "from jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n entries = fs_instance.listdir(\"{{path}}\")" } ] }, diff --git a/jupyterfs/__init__.py b/jupyterfs/__init__.py index ae6f53ae..cfbf588b 100644 --- a/jupyterfs/__init__.py +++ b/jupyterfs/__init__.py @@ -16,6 +16,15 @@ data = json.load(fid) +def open_fs(fs_url, **kwargs): + """Wrapper around fs.open_fs with {{variable}} substitution""" + import fs + from .auth import stdin_prompt + # substitute credential variables via `getpass` queries + fs_url = stdin_prompt(fs_url) + return fs.open_fs(fs_url, **kwargs) + + def _jupyter_labextension_paths(): return [ { diff --git a/jupyterfs/auth.py b/jupyterfs/auth.py index b76b780b..7806be08 100644 --- a/jupyterfs/auth.py +++ b/jupyterfs/auth.py @@ -36,6 +36,41 @@ class DoubleBraceTemplate(_BaseTemplate): """ +if not hasattr(DoubleBraceTemplate, "get_identifiers"): + # back-fill of 3.11 method. Th function body is copied from CPython under the + # Python Software Foundation License Version 2. And is subject to the below copy right: + # Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, + # 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; + # All Rights Reserved + + def get_identifiers(self): + ids = [] + for mo in self.pattern.finditer(self.template): + named = mo.group('named') or mo.group('braced') + if named is not None and named not in ids: + # add a named group only the first time it appears + ids.append(named) + elif (named is None + and mo.group('invalid') is None + and mo.group('escaped') is None): + # If all the groups are None, there must be + # another group we're not expecting + raise ValueError('Unrecognized named group in pattern', + self.pattern) + return ids + + setattr(DoubleBraceTemplate, "get_identifiers", get_identifiers) + + +def stdin_prompt(url): + from getpass import getpass + template = DoubleBraceTemplate(url) + subs = {} + for ident in template.get_identifiers(): + subs[ident] = urllib.parse.quote(getpass(f"Enter value for {ident!r}: ")) + return template.safe_substitute(subs) + + def substituteAsk(resource): if "tokenDict" in resource: url = DoubleBraceTemplate(resource["url"]).safe_substitute( From a9a6bc58cbe29cedcf9b83a0fa333423f6207ab1 Mon Sep 17 00:00:00 2001 From: Jacob Frazer <64307592+jacob-frazer@users.noreply.github.com> Date: Wed, 13 Sep 2023 09:40:01 -0400 Subject: [PATCH 33/49] More snippets + manual combination snippets migrate to have defaults only after version update --- js/package.json | 1 + js/schema/plugin.json | 56 +++++++++++++++++++++++++++++++++---------- js/src/filesystem.ts | 5 ++++ js/src/index.tsx | 10 +++++++- js/src/settings.ts | 46 +++++++++++++++++++++++++++++++++++ js/src/snippets.tsx | 2 +- 6 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 js/src/settings.ts diff --git a/js/package.json b/js/package.json index d4f4738a..0a2f6439 100644 --- a/js/package.json +++ b/js/package.json @@ -63,6 +63,7 @@ "jszip": "^3.7.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "semver": "^7.5.4", "tree-finder": "^0.0.13" }, "devDependencies": { diff --git a/js/schema/plugin.json b/js/schema/plugin.json index 58c263e0..83083980 100644 --- a/js/schema/plugin.json +++ b/js/schema/plugin.json @@ -5,7 +5,6 @@ "jupyter.lab.setting-icon": "jfs:drive", "jupyter.lab.setting-icon-label": "jupyter-fs", "type": "object", - "additionalProperties": false, "jupyter.lab.shortcuts": [ { @@ -39,7 +38,6 @@ "resource": { "description": "Specification for an fs resource", "type": "object", - "additionalProperties": false, "properties": { "name": { "description": "Display name of resource", @@ -69,7 +67,6 @@ "snippet": { "description": "Per entry snippets for how to use it, e.g. a snippet for how to open a file from a given resource", "type": "object", - "additionalProperties": false, "properties": { "label": { "description": "The designator to show to users", @@ -111,31 +108,61 @@ "label": "Read CSV as dataframe", "caption": "Read the contents of this CSV file as a pandas DataFrame", "pattern": "\\.csv$", - "template": "from jupyterfs import open_fs\nimport pandas\nwith open_fs(\"{{url}}\") as fs_instance:\n df = pandas.read_csv(fs_instance.openbin(\"{{path}}\"))" + "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.openbin \nfrom jupyterfs import open_fs\nimport pandas\nwith open_fs(\"{{url}}\") as fs_instance:\n df = pandas.read_csv(fs_instance.openbin(\"{{path}}\"))" }, { "label": "Read contents", "caption": "Read the contents of this file with PyFilesystem", - "pattern": "^(?!:osfs://)(.*/)?[^/]+$", - "template": "from jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n contents = fs_instance.readbytes(\"{{path}}\")" + "pattern": "^(?!osfs://)(.*/)?[^/]+$", + "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.readbytes \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n contents = fs_instance.readbytes(\"{{path}}\")" }, { "label": "Read contents (local)", - "caption": "Read the contents of this file with PyFilesystem", - "pattern": "^(?:osfs://)(.*/)?[^/]+$", - "template": "import pathlib;\ncontents = pathlib.Path(\"{{resource}}/{{path}}\").read_bytes()" + "caption": "Read the contents of this local file", + "pattern": "^(?=osfs://)(.*/)?[^/]+$", + "template": "import pathlib;\ncontents = pathlib.Path(\"{{path}}\").read_bytes()" }, { "label": "Read text", "caption": "Read the contents of this file as text with PyFilesystem", - "pattern": "^(?!:osfs://)(.*/)?[^/]+$", - "template": "from jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n contents = fs_instance.readtext(\"{{path}}\")" + "pattern": "^(?!osfs://)(.*/)?[^/]+$", + "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.readtext \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n contents = fs_instance.readtext(\"{{path}}\")" }, { "label": "List directory contents", "caption": "List the entries of this directory with PyFilesystem", "pattern": "^.*/$", - "template": "from jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n entries = fs_instance.listdir(\"{{path}}\")" + "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.listdir \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n entries = fs_instance.listdir(\"{{path}}\")" + }, + { + "label": "Read Excel as dataframe", + "caption": "Read the contents of this Excel file as a pandas DataFrame", + "pattern": "\\.(xls|xlsx|xlsm|xlsb)$", + "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.open \nfrom jupyterfs import open_fs\nimport pandas\nwith open_fs(\"{{url}}\") as fs_instance:\n df = pandas.read_excel(fs_instance.open(\"{{path}}\", mode=\"rb\"))" + }, + { + "label": "Write dataframe as CSV", + "caption": "Save a pandas DF from the session to the given CSV file with PyFilesystem", + "pattern": "\\.csv$", + "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.writetext \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n fs_instance.writetext(\"{{path}}\", YOUR_DF_VARIABLE.to_csv())" + }, + { + "label": "Write contents", + "caption": "Write the given contents (bytes) to the specified file with PyFilesystem", + "pattern": "^(?!osfs://)(.*/)?[^/]+$", + "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.writebytes \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n fs_instance.writebytes(\"{{path}}\", YOUR_BYTES_CONTENT)" + }, + { + "label": "Write contents (local)", + "caption": "Write the contents to this local file", + "pattern": "^(?=osfs://)(.*/)?[^/]+$", + "template": "import pathlib;\ncontents = pathlib.Path(\"{{path}}\").write_bytes(YOUR_BYTES_CONTENT)" + }, + { + "label": "Write text", + "caption": "Write the given text to the specified file with PyFilesystem", + "pattern": "^(?!osfs://)(.*/)?[^/]+$", + "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.writetext \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n fs_instance.writetext(\"{{path}}\", YOUR_TEXT_CONTENT)" } ] }, @@ -143,7 +170,6 @@ "title": "Options", "description": "Global options for jupyter-fs", "type": "object", - "additionalProperties": false, "properties": { "cache": { "description": "If true, only recreate the actual resources when necessary (ie on initial load or changes to 'resources')", @@ -154,6 +180,10 @@ "description": "If true, jupyter-fs will output helpful info messages to the console", "type": "boolean", "default": false + }, + "writtenVersion": { + "description": "The version of the schema these settings were written with (do not edit)", + "type": "string" } }, "default": {} diff --git a/js/src/filesystem.ts b/js/src/filesystem.ts index f8a18272..e3b84cc3 100644 --- a/js/src/filesystem.ts +++ b/js/src/filesystem.ts @@ -22,6 +22,11 @@ export interface IFSOptions { * If true, enable jupyter-fs debug output in both frontend and backend */ verbose: boolean; + + /** + * The version of the package that these settings were written with + */ + writtenVersion: string; } export interface IFSResource { diff --git a/js/src/index.tsx b/js/src/index.tsx index 3f115248..74758740 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -14,15 +14,17 @@ import { ISettingRegistry } from "@jupyterlab/settingregistry"; import { IStatusBar } from "@jupyterlab/statusbar"; import { ITranslator } from "@jupyterlab/translation"; import { folderIcon, fileIcon, IFormComponentRegistry } from "@jupyterlab/ui-components"; -import { DisposableSet, IDisposable } from "@lumino/disposable"; +import { IDisposable } from "@lumino/disposable"; import * as React from "react"; import * as ReactDOM from "react-dom"; +import * as semver from "semver"; import { AskDialog, askRequired } from "./auth"; import { commandIDs, createDynamicCommands, createStaticCommands, idFromResource } from "./commands"; import { ContentsProxy } from "./contents_proxy"; import { FSComm, IFSOptions, IFSResource } from "./filesystem"; import { FileUploadStatus } from "./progress"; +import { migrateSettings } from "./settings"; import { snippetFormRender } from "./snippets"; import { TreeFinderSidebar } from "./treefinder"; import { ITreeFinderMain } from "./tokens"; @@ -69,6 +71,12 @@ export const browser: JupyterFrontEndPlugin = { console.warn(`Failed to load settings for the jupyter-fs extension.\n${error}`); } + // Migrate any old settings + const options = settings?.composite.options as unknown as IFSOptions | undefined; + if ((settings && semver.lt(options?.writtenVersion || "0.0.0", settings.version))) { + settings = await migrateSettings(settings); + } + if (editorRegistry) { editorRegistry.addRenderer("snippets", snippetFormRender); // Format for lab 4.x + diff --git a/js/src/settings.ts b/js/src/settings.ts new file mode 100644 index 00000000..880b97e7 --- /dev/null +++ b/js/src/settings.ts @@ -0,0 +1,46 @@ +/****************************************************************************** + * + * Copyright (c) 2019, the jupyter-fs authors. + * + * This file is part of the jupyter-fs library, distributed under the terms of + * the Apache License 2.0. The full license can be found in the LICENSE file. + * + */ + +import { ISettingRegistry } from "@jupyterlab/settingregistry"; +import * as semver from "semver"; +import type { IFSOptions } from "./filesystem"; +import type { RawSnippet } from "./snippets"; + +/** + * Migrate any settings from an older version of the package + * + * @param settings Our settings to consider for migratation + * @returns The modified settings object + */ +export async function migrateSettings(settings: ISettingRegistry.ISettings): Promise { + const options = settings?.composite.options as unknown as IFSOptions | undefined; + if (semver.lt(options?.writtenVersion || "0.0.0", "0.4.0-alpha.8")) { + // Migrate snippets to include defaults that were updated after version checked + const defaultSnippets = (settings?.default("snippets") ?? []) as unknown as RawSnippet[]; + const defaultLabels = defaultSnippets.map( (snippet) => snippet.label ); + const userSnippets = (settings?.user.snippets ?? []) as unknown as RawSnippet[]; + + // add the user defined snippets if they have different label to defaults + let raw = userSnippets.reduce((combinedSnippetsArray, snippet) => { + if (!defaultLabels.includes(snippet.label)) { + combinedSnippetsArray.push(snippet); + } + return combinedSnippetsArray; + }, [...defaultSnippets]) ?? []; + + await settings.set("snippets", raw as Array>) + } + + // Update version + await settings.set("options", { + ...options, + writtenVersion: settings.version + }); + return settings; +} diff --git a/js/src/snippets.tsx b/js/src/snippets.tsx index 47bf9188..5ecac4f4 100644 --- a/js/src/snippets.tsx +++ b/js/src/snippets.tsx @@ -37,7 +37,7 @@ export function snippetFormRender(props: FieldProps) { } -interface RawSnippet { +export interface RawSnippet { label: string; caption: string; pattern: string; From 97ba3d7299b2810b9276b04ca8f519ab69b450e8 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 21 Sep 2023 14:48:06 +0100 Subject: [PATCH 34/49] Factor our initResource We don't currently use different base urls for comms, so simplify the usage somewhat. This allows us to extract out the init + optionally ask for credentials logic, making the code easier to read. --- js/src/filesystem.ts | 22 ++++++------- js/src/index.tsx | 60 +++-------------------------------- js/src/resources.tsx | 75 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 66 deletions(-) create mode 100644 js/src/resources.tsx diff --git a/js/src/filesystem.ts b/js/src/filesystem.ts index e3b84cc3..75aa5458 100644 --- a/js/src/filesystem.ts +++ b/js/src/filesystem.ts @@ -97,12 +97,7 @@ export interface IFSComm { abstract class FSCommBase implements IFSComm { protected _settings: ServerConnection.ISettings | undefined = undefined; - constructor(props: { baseUrl?: string } = {}) { - const { baseUrl } = props; - - if (baseUrl) { - this.baseUrl = baseUrl; - } + constructor() { } abstract getResourcesRequest(): Promise; @@ -111,11 +106,6 @@ abstract class FSCommBase implements IFSComm { get baseUrl(): string { return this.settings.baseUrl; } - set baseUrl(baseUrl: string) { - if (baseUrl !== this.baseUrl) { - this._settings = ServerConnection.makeSettings({ baseUrl }); - } - } get resourcesUrl(): string { return URLExt.join(this.baseUrl, "jupyterfs/resources"); @@ -131,6 +121,16 @@ abstract class FSCommBase implements IFSComm { } export class FSComm extends FSCommBase { + + private static _instance: FSComm; + + static get instance(): FSComm { + if (FSComm._instance === undefined) { + FSComm._instance = new FSComm(); + } + return FSComm._instance; + } + async getResourcesRequest(): Promise { const settings = this.settings; const fullUrl = this.resourcesUrl; diff --git a/js/src/index.tsx b/js/src/index.tsx index 74758740..852c833f 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -15,19 +15,17 @@ import { IStatusBar } from "@jupyterlab/statusbar"; import { ITranslator } from "@jupyterlab/translation"; import { folderIcon, fileIcon, IFormComponentRegistry } from "@jupyterlab/ui-components"; import { IDisposable } from "@lumino/disposable"; -import * as React from "react"; -import * as ReactDOM from "react-dom"; import * as semver from "semver"; -import { AskDialog, askRequired } from "./auth"; import { commandIDs, createDynamicCommands, createStaticCommands, idFromResource } from "./commands"; import { ContentsProxy } from "./contents_proxy"; -import { FSComm, IFSOptions, IFSResource } from "./filesystem"; +import { IFSOptions, IFSResource } from "./filesystem"; import { FileUploadStatus } from "./progress"; import { migrateSettings } from "./settings"; import { snippetFormRender } from "./snippets"; import { TreeFinderSidebar } from "./treefinder"; import { ITreeFinderMain } from "./tokens"; +import { initResources } from "./resources"; // tslint:disable: variable-name @@ -58,7 +56,6 @@ export const browser: JupyterFrontEndPlugin = { themeManager: IThemeManager, editorRegistry: IFormComponentRegistry | null ): Promise { - const comm = new FSComm(); const widgetMap : {[key: string]: TreeFinderSidebar} = {}; let commands: IDisposable | undefined; @@ -149,56 +146,9 @@ export const browser: JupyterFrontEndPlugin = { } try { - // send user specs to backend; await return containing resources - // defined by user settings + resources defined by server config - resources = await comm.initResourceRequest({ - resources, - options: { - ...options, - _addServerside: true, - }, - }); - - if (askRequired(resources)) { - // ask for url template values, if required - const dialogElem = document.createElement("div"); - document.body.appendChild(dialogElem); - - let submitted = false; - const handleClose = async () => { - ReactDOM.unmountComponentAtNode(dialogElem); - dialogElem.remove(); - if (!submitted) { - // if prompt cancelled, refresh all inited resources - cleanup(); - await refreshWidgets({ resources: resources.filter(r => r.init), options }); - } - }; - - const handleSubmit = async (values: {[url: string]: {[key: string]: string}}) => { - submitted = true; - resources = await comm.initResourceRequest({ - resources: resources.map(r => ({ ...r, tokenDict: values[r.url] })), - options, - }); - cleanup(); - await refreshWidgets({ resources, options }); - }; - - ReactDOM.render( - , - dialogElem, - ); - } else { - // otherwise, just go ahead and refresh the widgets - cleanup(); - await refreshWidgets({ options, resources }); - } + resources = await initResources(resources, options); + cleanup(); + await refreshWidgets({resources, options}); } catch (e) { console.error("Failed to refresh widgets!", e); cleanup(true); diff --git a/js/src/resources.tsx b/js/src/resources.tsx new file mode 100644 index 00000000..7fe67afc --- /dev/null +++ b/js/src/resources.tsx @@ -0,0 +1,75 @@ +import { PromiseDelegate } from "@lumino/coreutils"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +import { askRequired, AskDialog } from "./auth"; +import { FSComm, IFSResource, IFSOptions } from "./filesystem"; + + +/** + * Init resource on the server, and prompt for credentials via a dialog if needed. + * + * @param resources The resources to initialize to. + * @param options The initialization options to use. + * @param onDone + */ +export async function initResources(resources: IFSResource[], options: IFSOptions): Promise { + const delegate = new PromiseDelegate(); + // send user specs to backend; await return containing resources + // defined by user settings + resources defined by server config + resources = await FSComm.instance.initResourceRequest({ + resources, + options: { + ...options, + _addServerside: true, + }, + }); + + if (askRequired(resources)) { + // ask for url template values, if required + const dialogElem = document.createElement("div"); + document.body.appendChild(dialogElem); + + let submitted = false; + const handleClose = async () => { + try { + ReactDOM.unmountComponentAtNode(dialogElem); + dialogElem.remove(); + if (!submitted) { + // if prompt cancelled, refresh all inited resources + delegate.resolve(resources.filter(r => r.init)); + } + } catch (e) { + delegate.reject(e); + } + }; + + const handleSubmit = async (values: {[url: string]: {[key: string]: string}}) => { + try { + submitted = true; + resources = await FSComm.instance.initResourceRequest({ + resources: resources.map(r => ({ ...r, tokenDict: values[r.url] })), + options, + }); + delegate.resolve(resources); + } catch (e) { + delegate.reject(e); + } + }; + + ReactDOM.render( + , + dialogElem, + ); + + } else { + delegate.resolve(resources); + } + return delegate.promise; +} \ No newline at end of file From 98a8793ef074fc992469d5b02c50e45ca98d3600 Mon Sep 17 00:00:00 2001 From: Jacob Frazer <64307592+jacob-frazer@users.noreply.github.com> Date: Mon, 2 Oct 2023 14:19:33 +0100 Subject: [PATCH 35/49] Decode fs url in UI --- js/src/auth.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/js/src/auth.tsx b/js/src/auth.tsx index ebb9e082..329dbbea 100644 --- a/js/src/auth.tsx +++ b/js/src/auth.tsx @@ -132,6 +132,7 @@ export class AskDialog< protected _formInner() { return this.props.resources.map(resource => { // ask for credentials if needed, or state why not + const decodedUrl = decodeURIComponent(resource.url) const askReq = _askRequired(resource); const inputs = askReq ? this._inputs(resource.url) : []; const tokens = tokensFromUrl(resource.url); @@ -155,8 +156,8 @@ export class AskDialog< {summary} {!reason && - - {resource.url} + + {decodedUrl} } From 8c477465e270fd5197bcd3fe58913943274f3468 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Mon, 2 Oct 2023 11:23:40 +0100 Subject: [PATCH 36/49] Lint fixes --- js/src/index.tsx | 2 +- js/src/resources.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/js/src/index.tsx b/js/src/index.tsx index 852c833f..04ab0476 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -148,7 +148,7 @@ export const browser: JupyterFrontEndPlugin = { try { resources = await initResources(resources, options); cleanup(); - await refreshWidgets({resources, options}); + await refreshWidgets({ resources, options }); } catch (e) { console.error("Failed to refresh widgets!", e); cleanup(true); diff --git a/js/src/resources.tsx b/js/src/resources.tsx index 7fe67afc..0b73ef0c 100644 --- a/js/src/resources.tsx +++ b/js/src/resources.tsx @@ -9,7 +9,7 @@ import { FSComm, IFSResource, IFSOptions } from "./filesystem"; /** * Init resource on the server, and prompt for credentials via a dialog if needed. - * + * * @param resources The resources to initialize to. * @param options The initialization options to use. * @param onDone @@ -67,9 +67,9 @@ export async function initResources(resources: IFSResource[], options: IFSOption />, dialogElem, ); - + } else { delegate.resolve(resources); } return delegate.promise; -} \ No newline at end of file +} From 90970197aa869be4313833b13ee082434fb69b4b Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Mon, 2 Oct 2023 11:42:44 +0100 Subject: [PATCH 37/49] Have server return all resources, even if uninited Instead, we now also enable the server to surface init errors to the user. This setting defaults to false to remain backwards compatible, in case any credentials system includes more details than the admin is comfortable revealing. --- js/src/filesystem.ts | 5 +++++ js/src/index.tsx | 2 +- js/src/resources.tsx | 5 ++--- jupyterfs/config.py | 6 ++++++ jupyterfs/metamanager.py | 9 ++++++--- 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/js/src/filesystem.ts b/js/src/filesystem.ts index 75aa5458..2be504ef 100644 --- a/js/src/filesystem.ts +++ b/js/src/filesystem.ts @@ -73,6 +73,11 @@ export interface IFSResource { * initialization */ tokenDict?: {[key: string]: string}; + + /** + * Any errors during initialization + */ + errors?: string[]; } export interface IFSComm { diff --git a/js/src/index.tsx b/js/src/index.tsx index 04ab0476..56d70259 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -146,7 +146,7 @@ export const browser: JupyterFrontEndPlugin = { } try { - resources = await initResources(resources, options); + resources = (await initResources(resources, options)).filter(r => r.init); cleanup(); await refreshWidgets({ resources, options }); } catch (e) { diff --git a/js/src/resources.tsx b/js/src/resources.tsx index 0b73ef0c..620e6477 100644 --- a/js/src/resources.tsx +++ b/js/src/resources.tsx @@ -12,7 +12,7 @@ import { FSComm, IFSResource, IFSOptions } from "./filesystem"; * * @param resources The resources to initialize to. * @param options The initialization options to use. - * @param onDone + * @returns All resources, whether inited or not */ export async function initResources(resources: IFSResource[], options: IFSOptions): Promise { const delegate = new PromiseDelegate(); @@ -37,8 +37,7 @@ export async function initResources(resources: IFSResource[], options: IFSOption ReactDOM.unmountComponentAtNode(dialogElem); dialogElem.remove(); if (!submitted) { - // if prompt cancelled, refresh all inited resources - delegate.resolve(resources.filter(r => r.init)); + delegate.resolve(resources); } } catch (e) { delegate.reject(e); diff --git a/jupyterfs/config.py b/jupyterfs/config.py index 6b8ce8f5..575af0bf 100644 --- a/jupyterfs/config.py +++ b/jupyterfs/config.py @@ -46,6 +46,12 @@ class JupyterFs(Configurable): ), ) + surface_init_errors = Bool( + default_value=False, + config=True, + help=_i18n("whether to surface init errors to the client") + ) + snippets = List( config=True, per_key_traits=Dict({ diff --git a/jupyterfs/metamanager.py b/jupyterfs/metamanager.py index a76f523f..97792ace 100644 --- a/jupyterfs/metamanager.py +++ b/jupyterfs/metamanager.py @@ -78,6 +78,7 @@ def initResource(self, *resources, options={}): _hash = md5(resource["url"].encode("utf-8")).hexdigest()[:8] init = False missingTokens = None + errors = [] if _hash in self._managers and cache: # reuse existing cm @@ -108,18 +109,20 @@ def initResource(self, *resources, options={}): parent=self, **self._pyfs_kw, ) - except (FSError, OpenerError, ParseError): + init = True + except (FSError, OpenerError, ParseError) as e: self.log.exception( "Failed to create manager for resource %r", resource.get("name"), ) - continue - init = True + errors.append(str(e)) # assemble resource from spec + hash newResource = {} newResource.update(resource) newResource.update({"drive": _hash, "init": init}) + if self._jupyterfsConfig.surface_init_errors: + newResource["errors"] = errors if missingTokens is not None: newResource["missingTokens"] = missingTokens From 7662f28b6af8359606bb1ef23c89f250ecd9475d Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Mon, 2 Oct 2023 13:21:57 +0100 Subject: [PATCH 38/49] Lint fixes --- js/src/auth.tsx | 2 +- js/src/filesystem.ts | 3 --- js/src/index.tsx | 4 ++-- js/src/resources.tsx | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/js/src/auth.tsx b/js/src/auth.tsx index 329dbbea..ca3e7f51 100644 --- a/js/src/auth.tsx +++ b/js/src/auth.tsx @@ -132,7 +132,7 @@ export class AskDialog< protected _formInner() { return this.props.resources.map(resource => { // ask for credentials if needed, or state why not - const decodedUrl = decodeURIComponent(resource.url) + const decodedUrl = decodeURIComponent(resource.url); const askReq = _askRequired(resource); const inputs = askReq ? this._inputs(resource.url) : []; const tokens = tokensFromUrl(resource.url); diff --git a/js/src/filesystem.ts b/js/src/filesystem.ts index 2be504ef..0af8de10 100644 --- a/js/src/filesystem.ts +++ b/js/src/filesystem.ts @@ -102,9 +102,6 @@ export interface IFSComm { abstract class FSCommBase implements IFSComm { protected _settings: ServerConnection.ISettings | undefined = undefined; - constructor() { - } - abstract getResourcesRequest(): Promise; abstract initResourceRequest(args: {options: IFSOptions; resources: IFSResource[]}): Promise; diff --git a/js/src/index.tsx b/js/src/index.tsx index 56d70259..6d707083 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -69,8 +69,8 @@ export const browser: JupyterFrontEndPlugin = { } // Migrate any old settings - const options = settings?.composite.options as unknown as IFSOptions | undefined; - if ((settings && semver.lt(options?.writtenVersion || "0.0.0", settings.version))) { + const initialOptions = settings?.composite.options as unknown as IFSOptions | undefined; + if ((settings && semver.lt(initialOptions?.writtenVersion || "0.0.0", settings.version))) { settings = await migrateSettings(settings); } diff --git a/js/src/resources.tsx b/js/src/resources.tsx index 620e6477..8f80d794 100644 --- a/js/src/resources.tsx +++ b/js/src/resources.tsx @@ -32,7 +32,7 @@ export async function initResources(resources: IFSResource[], options: IFSOption document.body.appendChild(dialogElem); let submitted = false; - const handleClose = async () => { + const handleClose = () => { try { ReactDOM.unmountComponentAtNode(dialogElem); dialogElem.remove(); From b0a0de05d063423f26a36b2ff929664b08e90100 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 5 Oct 2023 11:31:07 +0100 Subject: [PATCH 39/49] Handle empty resource fields from settings --- js/src/filesystem.ts | 32 +++++++++++++++++++++++++++++--- js/src/index.tsx | 8 +++++--- js/src/settings.ts | 23 +++++++++++++++++------ 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/js/src/filesystem.ts b/js/src/filesystem.ts index 0af8de10..b4d853b3 100644 --- a/js/src/filesystem.ts +++ b/js/src/filesystem.ts @@ -29,26 +29,52 @@ export interface IFSOptions { writtenVersion: string; } -export interface IFSResource { + +/** + * A resource configuration as stored in the settings system. + */ +export interface IFSSettingsResource { /** * The name of this resource */ - name: string; + name?: string; /** * The fsurl specifying this resource */ - url: string; + url?: string; /** * Auth scheme to be used for this resource, or false for none */ auth: "ask" | "env" | false; + /** + * Fallback for determining if resource is writeable. Used only if the underlying PyFilesystem does not provide this information (eg S3) + */ + defaultWritable?: boolean; + /** * Directory to be first opened */ preferred_dir?: string; +} + + +/** + * An object defining an FS resource. + */ +export interface IFSResource extends IFSSettingsResource { + /** + * The name of this resource + */ + name: string; + + /** + * The fsurl specifying this resource + */ + url: string; + /** * The jupyterlab drive name associated with this resource. This is defined diff --git a/js/src/index.tsx b/js/src/index.tsx index 6d707083..63004535 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -19,9 +19,9 @@ import * as semver from "semver"; import { commandIDs, createDynamicCommands, createStaticCommands, idFromResource } from "./commands"; import { ContentsProxy } from "./contents_proxy"; -import { IFSOptions, IFSResource } from "./filesystem"; +import { IFSOptions, IFSResource, IFSSettingsResource } from "./filesystem"; import { FileUploadStatus } from "./progress"; -import { migrateSettings } from "./settings"; +import { migrateSettings, unpartialResource } from "./settings"; import { snippetFormRender } from "./snippets"; import { TreeFinderSidebar } from "./treefinder"; import { ITreeFinderMain } from "./tokens"; @@ -128,7 +128,9 @@ export const browser: JupyterFrontEndPlugin = { async function refresh() { // get user settings from json file - let resources: IFSResource[] = settings!.composite.resources as any; + let resources: IFSResource[] = ( + settings!.composite.resources as unknown as IFSSettingsResource[] + ).map(unpartialResource); const options: IFSOptions = settings!.composite.options as any; function cleanup(all=false) { diff --git a/js/src/settings.ts b/js/src/settings.ts index 880b97e7..30d08d14 100644 --- a/js/src/settings.ts +++ b/js/src/settings.ts @@ -9,12 +9,13 @@ import { ISettingRegistry } from "@jupyterlab/settingregistry"; import * as semver from "semver"; -import type { IFSOptions } from "./filesystem"; + +import type { IFSOptions, IFSResource, IFSSettingsResource } from "./filesystem"; import type { RawSnippet } from "./snippets"; /** * Migrate any settings from an older version of the package - * + * * @param settings Our settings to consider for migratation * @returns The modified settings object */ @@ -23,24 +24,34 @@ export async function migrateSettings(settings: ISettingRegistry.ISettings): Pro if (semver.lt(options?.writtenVersion || "0.0.0", "0.4.0-alpha.8")) { // Migrate snippets to include defaults that were updated after version checked const defaultSnippets = (settings?.default("snippets") ?? []) as unknown as RawSnippet[]; - const defaultLabels = defaultSnippets.map( (snippet) => snippet.label ); + const defaultLabels = defaultSnippets.map( snippet => snippet.label ); const userSnippets = (settings?.user.snippets ?? []) as unknown as RawSnippet[]; // add the user defined snippets if they have different label to defaults - let raw = userSnippets.reduce((combinedSnippetsArray, snippet) => { + const raw = userSnippets.reduce((combinedSnippetsArray, snippet) => { if (!defaultLabels.includes(snippet.label)) { combinedSnippetsArray.push(snippet); } return combinedSnippetsArray; }, [...defaultSnippets]) ?? []; - await settings.set("snippets", raw as Array>) + await settings.set("snippets", raw as Array>); } // Update version await settings.set("options", { ...options, - writtenVersion: settings.version + writtenVersion: settings.version, }); return settings; } + + +/** + * Ensure undefined string values from settings that are required are translated to empty strings + * @param settingsResoruce + * @returns A filled in setting object + */ +export function unpartialResource(settingsResource: IFSSettingsResource): IFSResource { + return {name: "", url: "", ...settingsResource}; +} From b9fe19a1d636942b9db9a5b2ea7563f011851b35 Mon Sep 17 00:00:00 2001 From: Jacob Frazer <64307592+jacob-frazer@users.noreply.github.com> Date: Thu, 5 Oct 2023 14:22:13 +0100 Subject: [PATCH 40/49] Adding basic test for new func --- js/tests/settings.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 js/tests/settings.test.ts diff --git a/js/tests/settings.test.ts b/js/tests/settings.test.ts new file mode 100644 index 00000000..2092b54d --- /dev/null +++ b/js/tests/settings.test.ts @@ -0,0 +1,17 @@ +import "isomorphic-fetch"; + +import { unpartialResource } from "../src/settings"; +import { IFSSettingsResource } from "../src/filesystem"; + +describe("unpartialResource", () => { + + it("should replace non-existing fields with empty string", () => { + + // blank resource missing name and url (like after pressing "add") + let resource: IFSSettingsResource = {auth: "ask", defaultWritable: true}; + let unpartialed = unpartialResource(resource); + + expect(unpartialed.name).toEqual("") + expect(unpartialed.url).toEqual("") + }) +}) \ No newline at end of file From 477dfbbbc283a925246df57189e929865e270dde Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Thu, 2 Nov 2023 15:26:51 +0000 Subject: [PATCH 41/49] Rebase clenaup --- js/schema/plugin.json | 3 ++- js/src/treefinder.ts | 5 ++--- jupyterfs/config.py | 1 - jupyterfs/fsmanager.py | 1 - 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/js/schema/plugin.json b/js/schema/plugin.json index 83083980..4d5c63ec 100644 --- a/js/schema/plugin.json +++ b/js/schema/plugin.json @@ -45,7 +45,8 @@ }, "url": { "description": "A url pointing to an fs resource, as per the PyFilesystem fsurl specification", - "type": "string" + "type": "string", + "pattern": "^.+?:\/\/((([^:]*):(|{{[^@]+}})@.*)|[^@]*)$" }, "preferred_dir": { "description": "Directory to be first opened (e.g., myDir/mySubdir)", diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index d49ac1d6..ce17ca97 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -41,7 +41,7 @@ import { Content, ContentsModel, Format, Path, TreeFinderGridElement, TreeFinder import { JupyterClipboard } from "./clipboard"; import { commandIDs, idFromResource } from "./commands"; import { ContentsProxy } from "./contents_proxy"; -import { getContentChild, getContentParent, revealPath, openDirRecursive } from "./contents_utils"; +import { getContentParent, revealPath, openDirRecursive } from "./contents_utils"; import { DragDropWidget, TABLE_HEADER_MIME } from "./drag"; import { IFSResource } from "./filesystem"; import { fileTreeIcon } from "./icons"; @@ -49,7 +49,6 @@ import { promptRename } from "./utils"; import { Uploader, UploadButton } from "./upload"; import { MimeData } from "@lumino/coreutils"; import { DropAction } from "@lumino/dragdrop"; -import { ContentsManager } from "@jupyterlab/services"; export class TreeFinderTracker extends WidgetTracker { @@ -100,7 +99,7 @@ export class TreeFinderWidget extends DragDropWidget { this.addClass("jp-tree-finder"); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.contentsProxy = new ContentsProxy(contents as ContentsManager, rootPath, this.onGetChildren.bind(this)); + this.contentsProxy = new ContentsProxy(contents, rootPath, this.onGetChildren.bind(this)); this.settings = settings; this.translator = translator || nullTranslator; diff --git a/jupyterfs/config.py b/jupyterfs/config.py index 575af0bf..93e3f125 100644 --- a/jupyterfs/config.py +++ b/jupyterfs/config.py @@ -39,7 +39,6 @@ class JupyterFs(Configurable): resource_validators = List( config=True, - default_value=[".*"], trait=Unicode(), help=_i18n( "regular expressions to match against resource URLs. At least one must match" diff --git a/jupyterfs/fsmanager.py b/jupyterfs/fsmanager.py index d5c04acf..e80acea5 100644 --- a/jupyterfs/fsmanager.py +++ b/jupyterfs/fsmanager.py @@ -11,7 +11,6 @@ from fs import errors, open_fs from fs.base import FS from fs.errors import NoSysPath, ResourceNotFound, PermissionDenied -import fs.path import mimetypes import pathlib import stat From e11c4fab58e6f84f8446a5e40e86bacadf5d8084 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Fri, 3 Nov 2023 12:07:07 +0000 Subject: [PATCH 42/49] Remove commented out code --- js/src/drag.ts | 175 ------------------------------------------------- 1 file changed, 175 deletions(-) diff --git a/js/src/drag.ts b/js/src/drag.ts index 6fb7b9e7..30f44089 100644 --- a/js/src/drag.ts +++ b/js/src/drag.ts @@ -910,178 +910,3 @@ abstract class FriendlyDragDrop extends DragDropWidget { return super.validateSource(event); } } - - -// /** -// * Drag and drop class for contents widgets -// */ -// export class DragDropContents extends FriendlyDragDrop { - -// protected move(mimeData: MimeData, target: HTMLElement): DropAction { -// if (!mimeData.hasData(CONTENTS_MIME)) { -// return 'none'; -// } -// return 'none'; -// } - -// protected addMimeData(handle: HTMLElement, mimeData: MimeData): void { - -// } - - -// /** -// * Handle the `'lm-dragenter'` event for the widget. -// */ -// protected findDropTarget(input: HTMLElement, mimeData: MimeData): HTMLElement | null { -// const target = super.findDropTarget(input, mimeData); -// if (!target) { -// return target; -// } -// const item = this._sortedItems[index]; -// if (item.type !== 'directory' || this.selection[item.path]) { -// return; -// } -// } - -// /** -// * Handle the `'lm-drop'` event for the widget. -// */ -// protected processDrop(dropTarget: HTMLElement, event: IDragEvent): void { -// // Get the path based on the target node. -// const basePath = getPath(dropTarget); - -// // Handle the items. -// const promises: Promise[] = []; -// 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. -// */ -// protected 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; -// 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 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); -// }); -// } -// } From 1df3ea17ddf08ce677935b5d6c36f4fc99330574 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Wed, 8 Nov 2023 12:00:02 +0000 Subject: [PATCH 43/49] Update branch to lab 4 --- js/package.json | 1 + js/schema/plugin.json | 2 +- js/src/contents_proxy.ts | 8 +- js/src/drag.ts | 38 ++-- js/src/filesystem.ts | 8 +- js/src/index.tsx | 14 +- js/src/snippets.tsx | 3 +- js/src/treefinder.ts | 4 +- js/style/treefinder.css | 2 + yarn.lock | 416 ++++++++++++++++++++------------------- 10 files changed, 249 insertions(+), 247 deletions(-) diff --git a/js/package.json b/js/package.json index 0a2f6439..c4d99f52 100644 --- a/js/package.json +++ b/js/package.json @@ -70,6 +70,7 @@ "@babel/core": "^7.0.0", "@babel/preset-env": "^7.20.2", "@jupyterlab/builder": "^4.0.0", + "@rjsf/utils": "^5.13.2", "@types/file-saver": "^2.0.1", "@types/jest": "^29.5.1", "@types/jszip": "^3.4.1", diff --git a/js/schema/plugin.json b/js/schema/plugin.json index 4d5c63ec..ea4037cc 100644 --- a/js/schema/plugin.json +++ b/js/schema/plugin.json @@ -46,7 +46,7 @@ "url": { "description": "A url pointing to an fs resource, as per the PyFilesystem fsurl specification", "type": "string", - "pattern": "^.+?:\/\/((([^:]*):(|{{[^@]+}})@.*)|[^@]*)$" + "pattern": "^.+?:\/\/((([^:]*):(|\\{[^@]+\\})@.*)|[^@]*)$" }, "preferred_dir": { "description": "Directory to be first opened (e.g., myDir/mySubdir)", diff --git a/js/src/contents_proxy.ts b/js/src/contents_proxy.ts index 303d2ecf..4797dd05 100644 --- a/js/src/contents_proxy.ts +++ b/js/src/contents_proxy.ts @@ -2,7 +2,7 @@ import { PromiseDelegate } from "@lumino/coreutils"; import { showErrorMessage } from "@jupyterlab/apputils"; -import { Contents, ContentsManager } from "@jupyterlab/services"; +import { Contents } from "@jupyterlab/services"; import { IContentRow, Path } from "tree-finder"; @@ -10,7 +10,7 @@ import { IContentRow, Path } from "tree-finder"; * Wrapper for a drive onto the contents manager. */ export class ContentsProxy { - constructor(contentsManager: ContentsManager, drive?: string, onGetChildren?: ContentsProxy.GetChildrenCallback) { + constructor(contentsManager: Contents.IManager, drive?: string, onGetChildren?: ContentsProxy.GetChildrenCallback) { this.contentsManager = contentsManager; this.drive = drive; this.onGetChildren = onGetChildren; @@ -42,7 +42,7 @@ export class ContentsProxy { return await this.contentsManager.getDownloadUrl(path); } - readonly contentsManager: ContentsManager; + readonly contentsManager: Contents.IManager; readonly drive?: string; readonly onGetChildren?: ContentsProxy.GetChildrenCallback; } @@ -71,7 +71,7 @@ export namespace ContentsProxy { return [first.split(":").pop(), ...rest].join("/"); } - export function toJupyterContentRow(row: Contents.IModel, contentsManager: ContentsManager, drive?: string, onGetChildren?: ContentsProxy.GetChildrenCallback): IJupyterContentRow { + export function toJupyterContentRow(row: Contents.IModel, contentsManager: Contents.IManager, drive?: string, onGetChildren?: ContentsProxy.GetChildrenCallback): IJupyterContentRow { const { path, type, ...rest } = row; const pathWithDrive = toFullPath(path, drive).replace(/\/$/, ""); diff --git a/js/src/drag.ts b/js/src/drag.ts index 30f44089..9bc665f9 100644 --- a/js/src/drag.ts +++ b/js/src/drag.ts @@ -67,7 +67,7 @@ import { } from "@lumino/coreutils"; import { - Drag, IDragEvent, DropAction, SupportedActions, + Drag } from "@lumino/dragdrop"; @@ -207,23 +207,23 @@ abstract class DropWidget extends Widget { handleEvent(event: Event): void { switch (event.type) { case "lm-dragenter": - this._evtDragEnter(event as IDragEvent); + this._evtDragEnter(event as Drag.Event); break; case "lm-dragleave": - this._evtDragLeave(event as IDragEvent); + this._evtDragLeave(event as Drag.Event); break; case "lm-dragover": - this._evtDragOver(event as IDragEvent); + this._evtDragOver(event as Drag.Event); break; case "lm-drop": - this.evtDrop(event as IDragEvent); + this.evtDrop(event as Drag.Event); break; default: break; } } - protected validateSource(event: IDragEvent) { + protected validateSource(event: Drag.Event) { return this.acceptDropsFromExternalSource || event.source === this; } @@ -234,7 +234,7 @@ abstract class DropWidget extends Widget { * - That the `dropTarget` is a valid drop target * - The value of `event.source` if `acceptDropsFromExternalSource` is false */ - protected abstract processDrop(dropTarget: HTMLElement, event: IDragEvent): void; + protected abstract processDrop(dropTarget: HTMLElement, event: Drag.Event): void; /** * Find a drop target from a given drag event target. @@ -272,7 +272,7 @@ abstract class DropWidget extends Widget { * Should normally only be overriden if you cannot achive your goal by * other overrides. */ - protected evtDrop(event: IDragEvent): void { + protected evtDrop(event: Drag.Event): void { let target = event.target as HTMLElement; while (target && target.parentElement) { if (target.classList.contains(DROP_TARGET_CLASS)) { @@ -323,7 +323,7 @@ abstract class DropWidget extends Widget { /** * Handle the `'lm-dragenter'` event for the widget. */ - private _evtDragEnter(event: IDragEvent): void { + private _evtDragEnter(event: Drag.Event): void { if (!this.validateSource(event)) { return; } @@ -340,7 +340,7 @@ abstract class DropWidget extends Widget { /** * Handle the `'lm-dragleave'` event for the widget. */ - private _evtDragLeave(event: IDragEvent): void { + private _evtDragLeave(event: Drag.Event): void { event.preventDefault(); event.stopPropagation(); this._clearDropTarget(); @@ -349,7 +349,7 @@ abstract class DropWidget extends Widget { /** * Handle the `'lm-dragover'` event for the widget. */ - private _evtDragOver(event: IDragEvent): void { + private _evtDragOver(event: Drag.Event): void { if (!this.validateSource(event)) { return; } @@ -469,7 +469,7 @@ abstract class DragDropWidgetBase extends DropWidget { /** * Called when a drag has completed with this widget as a source */ - protected onDragComplete(action: DropAction) { + protected onDragComplete(action: Drag.DropAction) { this.drag = null; } @@ -616,8 +616,8 @@ abstract class DragDropWidgetBase extends DropWidget { this._clickData = null; } - protected defaultSupportedActions: SupportedActions = "all"; - protected defaultProposedAction: DropAction = "move"; + protected defaultSupportedActions: Drag.SupportedActions = "all"; + protected defaultProposedAction: Drag.DropAction = "move"; /** * Data stored on mouse down to determine if drag treshold has @@ -662,7 +662,7 @@ abstract class DragWidget extends DragDropWidgetBase { /** * No-op on DragWidget, as it does not support dropping */ - protected processDrop(dropTarget: HTMLElement, event: IDragEvent): void { + protected processDrop(dropTarget: HTMLElement, event: Drag.Event): void { // Intentionally empty } @@ -717,7 +717,7 @@ abstract class DragWidget extends DragDropWidgetBase { * For maximum control, `startDrag` and `evtDrop` can be overriden. */ export abstract class DragDropWidget extends DragDropWidgetBase { - protected abstract move(mimeData: MimeData, target: HTMLElement): DropAction; + protected abstract move(mimeData: MimeData, target: HTMLElement): Drag.DropAction; /** * Adds mime data represeting the drag data to the drag event's MimeData bundle. @@ -748,7 +748,7 @@ export abstract class DragDropWidget extends DragDropWidgetBase { * * Override this if you need to handle other mime data than the default. */ - protected processDrop(dropTarget: HTMLElement, event: IDragEvent): void { + protected processDrop(dropTarget: HTMLElement, event: Drag.Event): void { if (!DropWidget.isValidAction(event.supportedActions, "move") || event.proposedAction === "none") { // The default implementation only handles move action @@ -801,7 +801,7 @@ namespace DropWidget { * Validate a drop action against a SupportedActions type */ export - function isValidAction(supported: SupportedActions, action: DropAction): boolean { + function isValidAction(supported: Drag.SupportedActions, action: Drag.DropAction): boolean { switch (supported) { case "all": return true; @@ -903,7 +903,7 @@ abstract class FriendlyDragDrop extends DragDropWidget { private _groupId: number; - protected validateSource(event: IDragEvent) { + protected validateSource(event: Drag.Event) { if (this.acceptDropsFromExternalSource) { return this.friends.indexOf(event.source) !== -1; } diff --git a/js/src/filesystem.ts b/js/src/filesystem.ts index b4d853b3..3ea3b4d7 100644 --- a/js/src/filesystem.ts +++ b/js/src/filesystem.ts @@ -11,22 +11,22 @@ import { URLExt } from "@jupyterlab/coreutils"; import { ServerConnection } from "@jupyterlab/services"; export interface IFSOptions { - _addServerside: boolean; + _addServerside?: boolean; /** * If true, only recreate the actual resource when necessary */ - cache: boolean; + cache?: boolean; /** * If true, enable jupyter-fs debug output in both frontend and backend */ - verbose: boolean; + verbose?: boolean; /** * The version of the package that these settings were written with */ - writtenVersion: string; + writtenVersion?: string; } diff --git a/js/src/index.tsx b/js/src/index.tsx index 63004535..a88e2145 100644 --- a/js/src/index.tsx +++ b/js/src/index.tsx @@ -13,7 +13,7 @@ import { IDocumentManager } from "@jupyterlab/docmanager"; import { ISettingRegistry } from "@jupyterlab/settingregistry"; import { IStatusBar } from "@jupyterlab/statusbar"; import { ITranslator } from "@jupyterlab/translation"; -import { folderIcon, fileIcon, IFormComponentRegistry } from "@jupyterlab/ui-components"; +import { folderIcon, fileIcon, IFormRendererRegistry } from "@jupyterlab/ui-components"; import { IDisposable } from "@lumino/disposable"; import * as semver from "semver"; @@ -42,7 +42,7 @@ export const browser: JupyterFrontEndPlugin = { ISettingRegistry, IThemeManager, ], - optional: [IFormComponentRegistry], + optional: [IFormRendererRegistry], provides: ITreeFinderMain, async activate( @@ -54,7 +54,7 @@ export const browser: JupyterFrontEndPlugin = { router: IRouter, settingRegistry: ISettingRegistry, themeManager: IThemeManager, - editorRegistry: IFormComponentRegistry | null + editorRegistry: IFormRendererRegistry | null ): Promise { const widgetMap : {[key: string]: TreeFinderSidebar} = {}; let commands: IDisposable | undefined; @@ -75,9 +75,7 @@ export const browser: JupyterFrontEndPlugin = { } if (editorRegistry) { - editorRegistry.addRenderer("snippets", snippetFormRender); - // Format for lab 4.x + - // editorRegistry.addRenderer(`${BROWSER_ID}:snippets`, snippetFormRender); + editorRegistry.addRenderer(`${BROWSER_ID}.snippets`, { fieldRenderer: snippetFormRender }); } let columns = settings?.composite.display_columns as Array ?? ["size"]; @@ -129,9 +127,9 @@ export const browser: JupyterFrontEndPlugin = { async function refresh() { // get user settings from json file let resources: IFSResource[] = ( - settings!.composite.resources as unknown as IFSSettingsResource[] + settings?.composite.resources as unknown as IFSSettingsResource[] ?? [] ).map(unpartialResource); - const options: IFSOptions = settings!.composite.options as any; + const options: IFSOptions = settings?.composite.options as any ?? {}; function cleanup(all=false) { if (commands) { diff --git a/js/src/snippets.tsx b/js/src/snippets.tsx index 5ecac4f4..196a38cd 100644 --- a/js/src/snippets.tsx +++ b/js/src/snippets.tsx @@ -12,8 +12,7 @@ import { ServerConnection } from "@jupyterlab/services"; import { ISettingRegistry } from "@jupyterlab/settingregistry"; import * as React from "react"; -// for lab 4, import this from @rjsf/utils: -import type { FieldProps } from "@rjsf/core"; +import type { FieldProps } from "@rjsf/utils"; import { splitPathstrDrive } from "./contents_utils"; function _mknode(obj: any, paths: string[]) { diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts index ce17ca97..1ba62de0 100644 --- a/js/src/treefinder.ts +++ b/js/src/treefinder.ts @@ -48,7 +48,7 @@ import { fileTreeIcon } from "./icons"; import { promptRename } from "./utils"; import { Uploader, UploadButton } from "./upload"; import { MimeData } from "@lumino/coreutils"; -import { DropAction } from "@lumino/dragdrop"; +import { Drag } from "@lumino/dragdrop"; export class TreeFinderTracker extends WidgetTracker { @@ -122,7 +122,7 @@ export class TreeFinderWidget extends DragDropWidget { }); }); } - protected move(mimeData: MimeData, target: HTMLElement): DropAction { + protected move(mimeData: MimeData, target: HTMLElement): Drag.DropAction { const source = mimeData.getData(TABLE_HEADER_MIME) as (keyof ContentsProxy.IJupyterContentRow); const dest = target.innerText as (keyof ContentsProxy.IJupyterContentRow); void this._reorderColumns(source, dest); diff --git a/js/style/treefinder.css b/js/style/treefinder.css index 1ffa6b02..3f1f4251 100644 --- a/js/style/treefinder.css +++ b/js/style/treefinder.css @@ -149,6 +149,8 @@ tree-finder-panel tree-finder-grid table { } .jp-thead-drag-image { + top: 0px; + left: 0px; color: var(--jp-ui-font-color1); font-size: var(--jp-ui-font-size1); font-weight: 500; diff --git a/yarn.lock b/yarn.lock index 36baa3eb..0f87657e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1404,9 +1404,9 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^2.1.2": - version: 2.1.2 - resolution: "@eslint/eslintrc@npm:2.1.2" +"@eslint/eslintrc@npm:^2.1.3": + version: 2.1.3 + resolution: "@eslint/eslintrc@npm:2.1.3" dependencies: ajv: ^6.12.4 debug: ^4.3.2 @@ -1417,14 +1417,14 @@ __metadata: js-yaml: ^4.1.0 minimatch: ^3.1.2 strip-json-comments: ^3.1.1 - checksum: bc742a1e3b361f06fedb4afb6bf32cbd27171292ef7924f61c62f2aed73048367bcc7ac68f98c06d4245cd3fabc43270f844e3c1699936d4734b3ac5398814a7 + checksum: 5c6c3878192fe0ddffa9aff08b4e2f3bcc8f1c10d6449b7295a5f58b662019896deabfc19890455ffd7e60a5bd28d25d0eaefb2f78b2d230aae3879af92b89e5 languageName: node linkType: hard -"@eslint/js@npm:8.52.0": - version: 8.52.0 - resolution: "@eslint/js@npm:8.52.0" - checksum: 490893b8091a66415f4ac98b963d23eb287264ea3bd6af7ec788f0570705cf64fd6ab84b717785980f55e39d08ff5c7fde6d8e4391ccb507169370ce3a6d091a +"@eslint/js@npm:8.53.0": + version: 8.53.0 + resolution: "@eslint/js@npm:8.53.0" + checksum: e0d5cfb0000aaee237c8e6d6d6e366faa60b1ef7f928ce17778373aa44d3b886368f6d5e1f97f913f0f16801aad016db8b8df78418c9d18825c15590328028af languageName: node linkType: hard @@ -1791,19 +1791,19 @@ __metadata: linkType: hard "@jupyterlab/application@npm:^4.0.0": - version: 4.0.7 - resolution: "@jupyterlab/application@npm:4.0.7" + version: 4.0.8 + resolution: "@jupyterlab/application@npm:4.0.8" dependencies: "@fortawesome/fontawesome-free": ^5.12.0 - "@jupyterlab/apputils": ^4.1.7 - "@jupyterlab/coreutils": ^6.0.7 - "@jupyterlab/docregistry": ^4.0.7 - "@jupyterlab/rendermime": ^4.0.7 - "@jupyterlab/rendermime-interfaces": ^3.8.7 - "@jupyterlab/services": ^7.0.7 - "@jupyterlab/statedb": ^4.0.7 - "@jupyterlab/translation": ^4.0.7 - "@jupyterlab/ui-components": ^4.0.7 + "@jupyterlab/apputils": ^4.1.8 + "@jupyterlab/coreutils": ^6.0.8 + "@jupyterlab/docregistry": ^4.0.8 + "@jupyterlab/rendermime": ^4.0.8 + "@jupyterlab/rendermime-interfaces": ^3.8.8 + "@jupyterlab/services": ^7.0.8 + "@jupyterlab/statedb": ^4.0.8 + "@jupyterlab/translation": ^4.0.8 + "@jupyterlab/ui-components": ^4.0.8 "@lumino/algorithm": ^2.0.1 "@lumino/application": ^2.2.1 "@lumino/commands": ^2.1.3 @@ -1814,23 +1814,23 @@ __metadata: "@lumino/properties": ^2.0.1 "@lumino/signaling": ^2.1.2 "@lumino/widgets": ^2.3.0 - checksum: 4684edfcf7dfe9724e86938cf50a45a3518650dba3535bea9d13e024dcc9cd80a5862d2c1564b6498f6f086253766c0952eded677c93ce56b8b7265d739892c4 + checksum: e6c50720992d50f8d6151752a31bf8dda2a21e912e896bbe9037f72086bddb515836fb9554603df8773313b27f45a39e01bda7e7b75cb2aca70ef15bcae1bc5e languageName: node linkType: hard -"@jupyterlab/apputils@npm:^4.0.0, @jupyterlab/apputils@npm:^4.1.7": - version: 4.1.7 - resolution: "@jupyterlab/apputils@npm:4.1.7" - dependencies: - "@jupyterlab/coreutils": ^6.0.7 - "@jupyterlab/observables": ^5.0.7 - "@jupyterlab/rendermime-interfaces": ^3.8.7 - "@jupyterlab/services": ^7.0.7 - "@jupyterlab/settingregistry": ^4.0.7 - "@jupyterlab/statedb": ^4.0.7 - "@jupyterlab/statusbar": ^4.0.7 - "@jupyterlab/translation": ^4.0.7 - "@jupyterlab/ui-components": ^4.0.7 +"@jupyterlab/apputils@npm:^4.0.0, @jupyterlab/apputils@npm:^4.1.8": + version: 4.1.8 + resolution: "@jupyterlab/apputils@npm:4.1.8" + dependencies: + "@jupyterlab/coreutils": ^6.0.8 + "@jupyterlab/observables": ^5.0.8 + "@jupyterlab/rendermime-interfaces": ^3.8.8 + "@jupyterlab/services": ^7.0.8 + "@jupyterlab/settingregistry": ^4.0.8 + "@jupyterlab/statedb": ^4.0.8 + "@jupyterlab/statusbar": ^4.0.8 + "@jupyterlab/translation": ^4.0.8 + "@jupyterlab/ui-components": ^4.0.8 "@lumino/algorithm": ^2.0.1 "@lumino/commands": ^2.1.3 "@lumino/coreutils": ^2.1.2 @@ -1843,13 +1843,13 @@ __metadata: "@types/react": ^18.0.26 react: ^18.2.0 sanitize-html: ~2.7.3 - checksum: d8a3739ea4b74244b0e14e6a9bced973cc2fc8eb645fe25d36da960e3413492c5451332f44975ba601daecbe6b1e80073f36860f65482da16e94ed24f11a5947 + checksum: 1b028893ac0358d9f90585edd5fbb89a4fe251c31789cf6d809fb316b91c958c6ba33884d463dbe78dfdd864b579535e1e1849bcb9b16853002271a71418d31e languageName: node linkType: hard "@jupyterlab/builder@npm:^4.0.0": - version: 4.0.7 - resolution: "@jupyterlab/builder@npm:4.0.7" + version: 4.0.8 + resolution: "@jupyterlab/builder@npm:4.0.8" dependencies: "@lumino/algorithm": ^2.0.1 "@lumino/application": ^2.2.1 @@ -1884,22 +1884,22 @@ __metadata: worker-loader: ^3.0.2 bin: build-labextension: lib/build-labextension.js - checksum: 67b034c7843a41f63b314304a224480583d02b4d958fd874b3ea4b7fd9a2ec8df110edaaf0379937a7a1850cb19cf1178fbbadfe535f3dbd9acdc0c3a96b8f8a + checksum: 9a1feeba36ba85ac0f1538f8df1b5a2140e5d1786530b7351880b8fb45b2902e961a48c2625d619c0b5c09b68299d9fea045adf139e439fd0f7f3cce41794662 languageName: node linkType: hard -"@jupyterlab/codeeditor@npm:^4.0.7": - version: 4.0.7 - resolution: "@jupyterlab/codeeditor@npm:4.0.7" +"@jupyterlab/codeeditor@npm:^4.0.8": + version: 4.0.8 + resolution: "@jupyterlab/codeeditor@npm:4.0.8" dependencies: "@codemirror/state": ^6.2.0 "@jupyter/ydoc": ^1.0.2 - "@jupyterlab/coreutils": ^6.0.7 - "@jupyterlab/nbformat": ^4.0.7 - "@jupyterlab/observables": ^5.0.7 - "@jupyterlab/statusbar": ^4.0.7 - "@jupyterlab/translation": ^4.0.7 - "@jupyterlab/ui-components": ^4.0.7 + "@jupyterlab/coreutils": ^6.0.8 + "@jupyterlab/nbformat": ^4.0.8 + "@jupyterlab/observables": ^5.0.8 + "@jupyterlab/statusbar": ^4.0.8 + "@jupyterlab/translation": ^4.0.8 + "@jupyterlab/ui-components": ^4.0.8 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 "@lumino/dragdrop": ^2.1.3 @@ -1907,13 +1907,13 @@ __metadata: "@lumino/signaling": ^2.1.2 "@lumino/widgets": ^2.3.0 react: ^18.2.0 - checksum: d6c1c072b77f0afdc4c61ed9392297b43afa5ef0a3279e05631ead870122f9195eb9d5b6182b1ee984aa4fa7aee56051e710d601c550e43af27d43fc3397c333 + checksum: 151be40c60bcedf463d01b9c53466afc4b4747dd341fb4d5c2b9fc8b3c181af2ba391f867e10e78bb948848cdd300139b4b112634dec9f8aac9c36c5a13e1654 languageName: node linkType: hard -"@jupyterlab/coreutils@npm:^6.0.0, @jupyterlab/coreutils@npm:^6.0.7": - version: 6.0.7 - resolution: "@jupyterlab/coreutils@npm:6.0.7" +"@jupyterlab/coreutils@npm:^6.0.0, @jupyterlab/coreutils@npm:^6.0.8": + version: 6.0.8 + resolution: "@jupyterlab/coreutils@npm:6.0.8" dependencies: "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 @@ -1921,21 +1921,21 @@ __metadata: minimist: ~1.2.0 path-browserify: ^1.0.0 url-parse: ~1.5.4 - checksum: 18a14e0bc957bf087c3de3e86c5dc7ee568027906edf5dc820d9c794af6c9dece84d0b396e837786849f9144bb156746e3d4f2e838fd023a42eee94ebeb9014f + checksum: b56e3b95c0ce52745b79549ef5b18a27e620086b87cf997b3a743b59d18dc529e403c812751b7e294a4abc60ac957381301e14327e1a4b9c1afb232f181f3a4d languageName: node linkType: hard -"@jupyterlab/docmanager@npm:^4.0.0, @jupyterlab/docmanager@npm:^4.0.7": - version: 4.0.7 - resolution: "@jupyterlab/docmanager@npm:4.0.7" - dependencies: - "@jupyterlab/apputils": ^4.1.7 - "@jupyterlab/coreutils": ^6.0.7 - "@jupyterlab/docregistry": ^4.0.7 - "@jupyterlab/services": ^7.0.7 - "@jupyterlab/statusbar": ^4.0.7 - "@jupyterlab/translation": ^4.0.7 - "@jupyterlab/ui-components": ^4.0.7 +"@jupyterlab/docmanager@npm:^4.0.0, @jupyterlab/docmanager@npm:^4.0.8": + version: 4.0.8 + resolution: "@jupyterlab/docmanager@npm:4.0.8" + dependencies: + "@jupyterlab/apputils": ^4.1.8 + "@jupyterlab/coreutils": ^6.0.8 + "@jupyterlab/docregistry": ^4.0.8 + "@jupyterlab/services": ^7.0.8 + "@jupyterlab/statusbar": ^4.0.8 + "@jupyterlab/translation": ^4.0.8 + "@jupyterlab/ui-components": ^4.0.8 "@lumino/algorithm": ^2.0.1 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 @@ -1944,24 +1944,24 @@ __metadata: "@lumino/signaling": ^2.1.2 "@lumino/widgets": ^2.3.0 react: ^18.2.0 - checksum: 4ccbcfa431563cb0cdfa12d0f1ffed107816b8bcd420de5b6dc85e6c124ff1f691e72ce421102663880dc340717bfb71bdceb25eb8fc4074e08adb58ae6ba371 + checksum: 70eea965bb9edd6a4042c92d2cb98f8b1b145b6727a354e12e3f5ab84ff2ab7207c5d9433c43c03d01f4377f9dc901359d789d0f47d2c725e56f41c487295550 languageName: node linkType: hard -"@jupyterlab/docregistry@npm:^4.0.0, @jupyterlab/docregistry@npm:^4.0.7": - version: 4.0.7 - resolution: "@jupyterlab/docregistry@npm:4.0.7" +"@jupyterlab/docregistry@npm:^4.0.0, @jupyterlab/docregistry@npm:^4.0.8": + version: 4.0.8 + resolution: "@jupyterlab/docregistry@npm:4.0.8" dependencies: "@jupyter/ydoc": ^1.0.2 - "@jupyterlab/apputils": ^4.1.7 - "@jupyterlab/codeeditor": ^4.0.7 - "@jupyterlab/coreutils": ^6.0.7 - "@jupyterlab/observables": ^5.0.7 - "@jupyterlab/rendermime": ^4.0.7 - "@jupyterlab/rendermime-interfaces": ^3.8.7 - "@jupyterlab/services": ^7.0.7 - "@jupyterlab/translation": ^4.0.7 - "@jupyterlab/ui-components": ^4.0.7 + "@jupyterlab/apputils": ^4.1.8 + "@jupyterlab/codeeditor": ^4.0.8 + "@jupyterlab/coreutils": ^6.0.8 + "@jupyterlab/observables": ^5.0.8 + "@jupyterlab/rendermime": ^4.0.8 + "@jupyterlab/rendermime-interfaces": ^3.8.8 + "@jupyterlab/services": ^7.0.8 + "@jupyterlab/translation": ^4.0.8 + "@jupyterlab/ui-components": ^4.0.8 "@lumino/algorithm": ^2.0.1 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 @@ -1969,23 +1969,23 @@ __metadata: "@lumino/properties": ^2.0.1 "@lumino/signaling": ^2.1.2 "@lumino/widgets": ^2.3.0 - checksum: 1d420696305dc17b2e96b22bf31af2caf2b16e31529c57b824bf859c71ac5caecb5a0a00d32ebc34ca1af65f720cec2c442d786c0460da60d7f65deb402dd891 + checksum: 280697f97ca146cc711c5dafa1b27eaa89d96bc53fc92ade7d4b78c44c997feb9d2495b392c31e75ed3c836797865e2f3fa6ea8f3207f46a4ab2d26061dc9498 languageName: node linkType: hard "@jupyterlab/filebrowser@npm:^4.0.0": - version: 4.0.7 - resolution: "@jupyterlab/filebrowser@npm:4.0.7" - dependencies: - "@jupyterlab/apputils": ^4.1.7 - "@jupyterlab/coreutils": ^6.0.7 - "@jupyterlab/docmanager": ^4.0.7 - "@jupyterlab/docregistry": ^4.0.7 - "@jupyterlab/services": ^7.0.7 - "@jupyterlab/statedb": ^4.0.7 - "@jupyterlab/statusbar": ^4.0.7 - "@jupyterlab/translation": ^4.0.7 - "@jupyterlab/ui-components": ^4.0.7 + version: 4.0.8 + resolution: "@jupyterlab/filebrowser@npm:4.0.8" + dependencies: + "@jupyterlab/apputils": ^4.1.8 + "@jupyterlab/coreutils": ^6.0.8 + "@jupyterlab/docmanager": ^4.0.8 + "@jupyterlab/docregistry": ^4.0.8 + "@jupyterlab/services": ^7.0.8 + "@jupyterlab/statedb": ^4.0.8 + "@jupyterlab/statusbar": ^4.0.8 + "@jupyterlab/translation": ^4.0.8 + "@jupyterlab/ui-components": ^4.0.8 "@lumino/algorithm": ^2.0.1 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 @@ -1997,87 +1997,87 @@ __metadata: "@lumino/virtualdom": ^2.0.1 "@lumino/widgets": ^2.3.0 react: ^18.2.0 - checksum: 586b8a07fbe0a9bb0b0cd13a9d6fb083e797831a41fc5273d70124bb2daeeeb641e6b4584fc752a4799a5961bb14acc1379fd09847ef7f38b2908516b9f254e3 + checksum: 907dade3b9ab6bde667cdf2acc76c0fb2d631c06d794115a456cb6b1995a3b89b2a9f5c53a75824b337833cd05967c55fd919ca1311f24279aa5f067f948378a languageName: node linkType: hard -"@jupyterlab/nbformat@npm:^3.0.0 || ^4.0.0-alpha.21 || ^4.0.0, @jupyterlab/nbformat@npm:^4.0.7": - version: 4.0.7 - resolution: "@jupyterlab/nbformat@npm:4.0.7" +"@jupyterlab/nbformat@npm:^3.0.0 || ^4.0.0-alpha.21 || ^4.0.0, @jupyterlab/nbformat@npm:^4.0.8": + version: 4.0.8 + resolution: "@jupyterlab/nbformat@npm:4.0.8" dependencies: "@lumino/coreutils": ^2.1.2 - checksum: 32a14a6a3e6d068fa34aec385090531100170480869681156dfb510ea9154141277e678031a0df770af8ae5a0f06dc7c00570089c9187485552e1aeba5130ca8 + checksum: 2d8255ac7c7c20dbfa8497ce4d8d2f5840568adefb2feaec8eb8ddbb4892f50706ce60e8c4719113485c5523f720802f7e4e7b63ed43fac90f870ff1134bed7a languageName: node linkType: hard -"@jupyterlab/observables@npm:^5.0.7": - version: 5.0.7 - resolution: "@jupyterlab/observables@npm:5.0.7" +"@jupyterlab/observables@npm:^5.0.8": + version: 5.0.8 + resolution: "@jupyterlab/observables@npm:5.0.8" dependencies: "@lumino/algorithm": ^2.0.1 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 "@lumino/messaging": ^2.0.1 "@lumino/signaling": ^2.1.2 - checksum: 459ec3ec77a12534cd16864892d8d3af3ead32a56956daeb89ab68e16c53651c8f20021e088e5a601b214eed46398bbbaea8bc3dc23f23b2700660558fa7c317 + checksum: 833c6af7f66a338d53e4ebfae2c10c57a55b8a1710730eed89e7a0103a4dd27b7b5634d0e7cf9c7db47d891fd4c8e72235de9816833482ef68356846200613be languageName: node linkType: hard -"@jupyterlab/rendermime-interfaces@npm:^3.8.7": - version: 3.8.7 - resolution: "@jupyterlab/rendermime-interfaces@npm:3.8.7" +"@jupyterlab/rendermime-interfaces@npm:^3.8.8": + version: 3.8.8 + resolution: "@jupyterlab/rendermime-interfaces@npm:3.8.8" dependencies: "@lumino/coreutils": ^1.11.0 || ^2.1.2 "@lumino/widgets": ^1.37.2 || ^2.3.0 - checksum: 8095fc99f89e49ef6793e37d7864511cc182fd2260219d3fe94dc974ac34411d4daf898f237279bd5e097aea19cca04196356bf31bd774e94c77b54894baf71b + checksum: b356cc18acedd7eebbf9e6f03329ad58f0aadb676ef7ef6b64dec610857a53593662df54752bb58780d34f39938ec35c6940918513e3a3cef7c5893bd0909684 languageName: node linkType: hard -"@jupyterlab/rendermime@npm:^4.0.7": - version: 4.0.7 - resolution: "@jupyterlab/rendermime@npm:4.0.7" - dependencies: - "@jupyterlab/apputils": ^4.1.7 - "@jupyterlab/coreutils": ^6.0.7 - "@jupyterlab/nbformat": ^4.0.7 - "@jupyterlab/observables": ^5.0.7 - "@jupyterlab/rendermime-interfaces": ^3.8.7 - "@jupyterlab/services": ^7.0.7 - "@jupyterlab/translation": ^4.0.7 +"@jupyterlab/rendermime@npm:^4.0.8": + version: 4.0.8 + resolution: "@jupyterlab/rendermime@npm:4.0.8" + dependencies: + "@jupyterlab/apputils": ^4.1.8 + "@jupyterlab/coreutils": ^6.0.8 + "@jupyterlab/nbformat": ^4.0.8 + "@jupyterlab/observables": ^5.0.8 + "@jupyterlab/rendermime-interfaces": ^3.8.8 + "@jupyterlab/services": ^7.0.8 + "@jupyterlab/translation": ^4.0.8 "@lumino/coreutils": ^2.1.2 "@lumino/messaging": ^2.0.1 "@lumino/signaling": ^2.1.2 "@lumino/widgets": ^2.3.0 lodash.escape: ^4.0.1 - checksum: 8e7bc7dc8d569fa8748783d0d23b716deb64af530d2f6861f6a08fe66ace5ff0d75e3cc67eb4ebb50b2089574917fe0b65da0dcf5368c3539fdb78f595560885 + checksum: c1f9ebffc746fdc13c1b14a148fd2ae10132b5ca4e1eab27d18ac5bf3d3ae70cf2850b06f6c05a799f2c769792d81dab1447885d0cda7206c7cf63af10bbe4f2 languageName: node linkType: hard -"@jupyterlab/services@npm:^7.0.0, @jupyterlab/services@npm:^7.0.7": - version: 7.0.7 - resolution: "@jupyterlab/services@npm:7.0.7" +"@jupyterlab/services@npm:^7.0.0, @jupyterlab/services@npm:^7.0.8": + version: 7.0.8 + resolution: "@jupyterlab/services@npm:7.0.8" dependencies: "@jupyter/ydoc": ^1.0.2 - "@jupyterlab/coreutils": ^6.0.7 - "@jupyterlab/nbformat": ^4.0.7 - "@jupyterlab/settingregistry": ^4.0.7 - "@jupyterlab/statedb": ^4.0.7 + "@jupyterlab/coreutils": ^6.0.8 + "@jupyterlab/nbformat": ^4.0.8 + "@jupyterlab/settingregistry": ^4.0.8 + "@jupyterlab/statedb": ^4.0.8 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 "@lumino/polling": ^2.1.2 "@lumino/properties": ^2.0.1 "@lumino/signaling": ^2.1.2 ws: ^8.11.0 - checksum: 203f9e9eeab55eac9251c43d14ebaad881e8152a1337156ed7f2abbada54177237128c108f91a49f45df00226df8ba6a374d02afbd3bbd80ebb795cb5bc62e23 + checksum: b0112854d3014eff9d9855a6840d1efd0d866d4c011e7a9c4c1c5fba404dd13107b62de6ce845902d12cc6404aafdfee95127a2af43560ade53a00fc7b73378a languageName: node linkType: hard -"@jupyterlab/settingregistry@npm:^4.0.0, @jupyterlab/settingregistry@npm:^4.0.7": - version: 4.0.7 - resolution: "@jupyterlab/settingregistry@npm:4.0.7" +"@jupyterlab/settingregistry@npm:^4.0.0, @jupyterlab/settingregistry@npm:^4.0.8": + version: 4.0.8 + resolution: "@jupyterlab/settingregistry@npm:4.0.8" dependencies: - "@jupyterlab/nbformat": ^4.0.7 - "@jupyterlab/statedb": ^4.0.7 + "@jupyterlab/nbformat": ^4.0.8 + "@jupyterlab/statedb": ^4.0.8 "@lumino/commands": ^2.1.3 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 @@ -2087,28 +2087,28 @@ __metadata: json5: ^2.2.3 peerDependencies: react: ">=16" - checksum: f13dd888c42ccbcb1764037e94ea6b9ee6aa82a232cbb0d8b506212b9e9d5d58965215768110f83a310585482d71dfb649a7c2bbb187553d39dd1292b5919dbe + checksum: e9661539357edae60e4b300dff68b369e95e96acb343aeb25e23bdbcd6964c59dd40118ce3a856afaf969833958f3872c480e75cc488a5e882546cb88587c461 languageName: node linkType: hard -"@jupyterlab/statedb@npm:^4.0.7": - version: 4.0.7 - resolution: "@jupyterlab/statedb@npm:4.0.7" +"@jupyterlab/statedb@npm:^4.0.8": + version: 4.0.8 + resolution: "@jupyterlab/statedb@npm:4.0.8" dependencies: "@lumino/commands": ^2.1.3 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 "@lumino/properties": ^2.0.1 "@lumino/signaling": ^2.1.2 - checksum: 4f4217fa1fceb40be8837cb450b1e66d4f6758531603c82ac277412ec43e3b94a5207bdeb74339307509a1b059ae6436d653beaff2fadfbc8136434ff0967190 + checksum: bfd016e91158daf47e07e760126c0c2c3f6d01ecc8e9cad3e17241e5873decbc5fdfce82bf039fa83633b8760245af8003008f38272dafba56b73ac24768a99f languageName: node linkType: hard -"@jupyterlab/statusbar@npm:^4.0.0, @jupyterlab/statusbar@npm:^4.0.7": - version: 4.0.7 - resolution: "@jupyterlab/statusbar@npm:4.0.7" +"@jupyterlab/statusbar@npm:^4.0.0, @jupyterlab/statusbar@npm:^4.0.8": + version: 4.0.8 + resolution: "@jupyterlab/statusbar@npm:4.0.8" dependencies: - "@jupyterlab/ui-components": ^4.0.7 + "@jupyterlab/ui-components": ^4.0.8 "@lumino/algorithm": ^2.0.1 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 @@ -2116,31 +2116,31 @@ __metadata: "@lumino/signaling": ^2.1.2 "@lumino/widgets": ^2.3.0 react: ^18.2.0 - checksum: 7a2f75215789722a7b9a63548e91a1b179e8c315513d1b8741b508a58937569d723f2207bf542400674767246ad871432a09d1e87779151e43fa3749aa1ade06 + checksum: a07345a173e1c4500e5ce9aca6c8d619e5fecd928de0f6e88fd29241b39c09b85b26722279cc8119031d3015f2b32a0d3b9d85fd3cf9370c7605ebcd37d0d31a languageName: node linkType: hard -"@jupyterlab/translation@npm:^4.0.0, @jupyterlab/translation@npm:^4.0.7": - version: 4.0.7 - resolution: "@jupyterlab/translation@npm:4.0.7" +"@jupyterlab/translation@npm:^4.0.0, @jupyterlab/translation@npm:^4.0.8": + version: 4.0.8 + resolution: "@jupyterlab/translation@npm:4.0.8" dependencies: - "@jupyterlab/coreutils": ^6.0.7 - "@jupyterlab/rendermime-interfaces": ^3.8.7 - "@jupyterlab/services": ^7.0.7 - "@jupyterlab/statedb": ^4.0.7 + "@jupyterlab/coreutils": ^6.0.8 + "@jupyterlab/rendermime-interfaces": ^3.8.8 + "@jupyterlab/services": ^7.0.8 + "@jupyterlab/statedb": ^4.0.8 "@lumino/coreutils": ^2.1.2 - checksum: 15ad212d9447049f5d77d24681018efd52e61b861e73cdba4b09f4530801bdfa317c0eadde0b71016a9f45b68fbf91f723f9a63de9cbbe568c88923a9676ffab + checksum: 998d42d85ccd779237ac69abfaf2e341d865374ed5a1a4d234470337f498636511eec0562c741ad44a6a75fae930a510a0a76e176f72665499be2b7edb0dc5f8 languageName: node linkType: hard -"@jupyterlab/ui-components@npm:^4.0.0, @jupyterlab/ui-components@npm:^4.0.7": - version: 4.0.7 - resolution: "@jupyterlab/ui-components@npm:4.0.7" +"@jupyterlab/ui-components@npm:^4.0.0, @jupyterlab/ui-components@npm:^4.0.8": + version: 4.0.8 + resolution: "@jupyterlab/ui-components@npm:4.0.8" dependencies: - "@jupyterlab/coreutils": ^6.0.7 - "@jupyterlab/observables": ^5.0.7 - "@jupyterlab/rendermime-interfaces": ^3.8.7 - "@jupyterlab/translation": ^4.0.7 + "@jupyterlab/coreutils": ^6.0.8 + "@jupyterlab/observables": ^5.0.8 + "@jupyterlab/rendermime-interfaces": ^3.8.8 + "@jupyterlab/translation": ^4.0.8 "@lumino/algorithm": ^2.0.1 "@lumino/commands": ^2.1.3 "@lumino/coreutils": ^2.1.2 @@ -2158,7 +2158,7 @@ __metadata: typestyle: ^2.0.4 peerDependencies: react: ^18.2.0 - checksum: 92e722b8b4fe96a1df6644de8f955fdf48f2bf568a5aaf5f450f721659afc0ecdd9c89f833d73cbad8684849caec4316d4c6b6b0575e7da5a6c3058f5e99d03e + checksum: 7bf11f5ee3c1f88656175c0d3b290be0670d7787076a1eba944875e4780bc2b34c0b9a3af038806ff925620b3056cee36daff08f3ff91acc6c46fd1438bf004d languageName: node linkType: hard @@ -2170,13 +2170,13 @@ __metadata: linkType: hard "@lumino/application@npm:^2.2.1": - version: 2.2.1 - resolution: "@lumino/application@npm:2.2.1" + version: 2.3.0 + resolution: "@lumino/application@npm:2.3.0" dependencies: - "@lumino/commands": ^2.1.3 + "@lumino/commands": ^2.2.0 "@lumino/coreutils": ^2.1.2 - "@lumino/widgets": ^2.3.0 - checksum: a33e661703728440bc7d2ddb4674261f4de0d20eb8c9846646cbd6debac03b5c65e78d739a500903550fd83b8f47b47fa82ec178c97bc9967ca3ac4014075cde + "@lumino/widgets": ^2.3.1 + checksum: 9d1eb5bc972ed158bf219604a53bbac1262059bc5b0123d3e041974486b9cbb8288abeeec916f3b62f62d7c32e716cccf8b73e4832ae927e4f9dd4e4b0cd37ed languageName: node linkType: hard @@ -2189,9 +2189,9 @@ __metadata: languageName: node linkType: hard -"@lumino/commands@npm:^2.0.0, @lumino/commands@npm:^2.1.3": - version: 2.1.3 - resolution: "@lumino/commands@npm:2.1.3" +"@lumino/commands@npm:^2.0.0, @lumino/commands@npm:^2.1.3, @lumino/commands@npm:^2.2.0": + version: 2.2.0 + resolution: "@lumino/commands@npm:2.2.0" dependencies: "@lumino/algorithm": ^2.0.1 "@lumino/coreutils": ^2.1.2 @@ -2200,7 +2200,7 @@ __metadata: "@lumino/keyboard": ^2.0.1 "@lumino/signaling": ^2.1.2 "@lumino/virtualdom": ^2.0.1 - checksum: e4e3ee279f2a5e8d68e4ce142c880333f5542f90c684972402356936ecb5cf5e07163800b59e7cb8c911cbdb4e5089edcc5dd2990bc8db10c87517268de1fc5d + checksum: 093e9715491e5cef24bc80665d64841417b400f2fa595f9b60832a3b6340c405c94a6aa276911944a2c46d79a6229f3cc087b73f50852bba25ece805abd0fae9 languageName: node linkType: hard @@ -2227,13 +2227,13 @@ __metadata: languageName: node linkType: hard -"@lumino/dragdrop@npm:^2.1.3": - version: 2.1.3 - resolution: "@lumino/dragdrop@npm:2.1.3" +"@lumino/dragdrop@npm:^2.1.3, @lumino/dragdrop@npm:^2.1.4": + version: 2.1.4 + resolution: "@lumino/dragdrop@npm:2.1.4" dependencies: "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 - checksum: d5f7eb4cc9f9a084cb9af10f02d6741b25d683350878ecbc324e24ba9d4b5246451a410e2ca5fff227aab1c191d1e73a2faf431f93e13111d67a4e426e126258 + checksum: 43d82484b13b38b612e7dfb424a840ed6a38d0db778af10655c4ba235c67b5b12db1683929b35a36ab2845f77466066dfd1ee25c1c273e8e175677eba9dc560d languageName: node linkType: hard @@ -2291,22 +2291,22 @@ __metadata: languageName: node linkType: hard -"@lumino/widgets@npm:^1.37.2 || ^2.3.0, @lumino/widgets@npm:^2.0.0, @lumino/widgets@npm:^2.3.0": - version: 2.3.0 - resolution: "@lumino/widgets@npm:2.3.0" +"@lumino/widgets@npm:^1.37.2 || ^2.3.0, @lumino/widgets@npm:^2.0.0, @lumino/widgets@npm:^2.3.0, @lumino/widgets@npm:^2.3.1": + version: 2.3.1 + resolution: "@lumino/widgets@npm:2.3.1" dependencies: "@lumino/algorithm": ^2.0.1 - "@lumino/commands": ^2.1.3 + "@lumino/commands": ^2.2.0 "@lumino/coreutils": ^2.1.2 "@lumino/disposable": ^2.1.2 "@lumino/domutils": ^2.0.1 - "@lumino/dragdrop": ^2.1.3 + "@lumino/dragdrop": ^2.1.4 "@lumino/keyboard": ^2.0.1 "@lumino/messaging": ^2.0.1 "@lumino/properties": ^2.0.1 "@lumino/signaling": ^2.1.2 "@lumino/virtualdom": ^2.0.1 - checksum: a8559bd3574b7fc16e7679e05994c515b0d3e78dada35786d161f67c639941d134e92ce31d95c2e4ac06709cdf83b0e7fb4b6414a3f7779579222a2fb525d025 + checksum: ba7b8f8839c1cd2a41dbda13281094eb6981a270cccf4f25a0cf83686dcc526a2d8044a20204317630bb7dd4a04d65361408c7623a921549c781afca84b91c67 languageName: node linkType: hard @@ -2470,8 +2470,8 @@ __metadata: linkType: hard "@rjsf/core@npm:^5.1.0": - version: 5.13.3 - resolution: "@rjsf/core@npm:5.13.3" + version: 5.13.4 + resolution: "@rjsf/core@npm:5.13.4" dependencies: lodash: ^4.17.21 lodash-es: ^4.17.21 @@ -2481,13 +2481,13 @@ __metadata: peerDependencies: "@rjsf/utils": ^5.12.x react: ^16.14.0 || >=17 - checksum: ba7c855d2985bad845e75aadd1d5f227dde8715f14f9d1f2111cc502717eead93bc3430662fd9b04489a130e4100dc43eeb73a9c7d1c028655436dd7ca67eb4f + checksum: a263eca0064a62815a1e6a0492072c70834415a35dd67dd24a17f5ca77866c5878b57a1867b216a9a11e56f115b18822b4e20423dec984e22d0d7a8eed52eb22 languageName: node linkType: hard -"@rjsf/utils@npm:^5.1.0": - version: 5.13.3 - resolution: "@rjsf/utils@npm:5.13.3" +"@rjsf/utils@npm:^5.1.0, @rjsf/utils@npm:^5.13.2": + version: 5.13.4 + resolution: "@rjsf/utils@npm:5.13.4" dependencies: json-schema-merge-allof: ^0.8.1 jsonpointer: ^5.0.1 @@ -2496,7 +2496,7 @@ __metadata: react-is: ^18.2.0 peerDependencies: react: ^16.14.0 || >=17 - checksum: 7b2d3c7791a6f10b620f5cec9820994f6a2a728604848423c8d7534d762ff4f6ae64132077a7c9c99c7ec3c8166667fa911d3012c4971eea8387758233070ab1 + checksum: 9c55dd102a850cded70edc9de1a68b1d0b181d04abe59cbfd9b1cd42500dae6a271ff4e991925843447f881a579790ba28bc578a1373133fd164cad40b8f826e languageName: node linkType: hard @@ -2727,13 +2727,13 @@ __metadata: linkType: hard "@types/react@npm:*, @types/react@npm:^18.0.0, @types/react@npm:^18.0.26": - version: 18.2.33 - resolution: "@types/react@npm:18.2.33" + version: 18.2.36 + resolution: "@types/react@npm:18.2.36" dependencies: "@types/prop-types": "*" "@types/scheduler": "*" csstype: ^3.0.2 - checksum: 75903c4d53898c69dd23d0b2730eac4676dc5ade15c25c793dec855f0d7c650cb823832bb1dd881efe8895724f15b06d4bf7081ea0b82391aa3059512ad49ccf + checksum: 561fab294117983f3d245a63730bcffb423fc2a1b0f27d20c870abc5d980bc206a74f741cb11b5170fcdf0e747ac05448369cd930fbf345f74ed567f8fef3a9e languageName: node linkType: hard @@ -3735,9 +3735,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001541": - version: 1.0.30001559 - resolution: "caniuse-lite@npm:1.0.30001559" - checksum: 17c7af10244dca2c7ca41c884df19fc4c7313b9b03ed1f9b1f352bffbc871701f35431f766838f669ebe1f9d20627c0c6f2d760a0837538bd1d21de5833020f4 + version: 1.0.30001561 + resolution: "caniuse-lite@npm:1.0.30001561" + checksum: 949829fe037e23346595614e01d362130245920503a12677f2506ce68e1240360113d6383febed41e8aa38cd0f5fd9c69c21b0af65a71c0246d560db489f1373 languageName: node linkType: hard @@ -4317,9 +4317,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.4.535": - version: 1.4.572 - resolution: "electron-to-chromium@npm:1.4.572" - checksum: f7f111908febb03ba775dff02be288c0f9e3fe1766c22b915d6b40fdba30ee4aa899234513ca64028b56dd5d5a051b25e3e42fda557f57d149416fc325147706 + version: 1.4.576 + resolution: "electron-to-chromium@npm:1.4.576" + checksum: cf26065d95b0ecdd01f6daeec15b1e684a3b39680ae9f6d5d33c1391a6c1aa5ea2cb81cce699878e4fbb942328fc1076726f2d9ab1e8df9fbe02c84f1a5bbf1f languageName: node linkType: hard @@ -4726,13 +4726,13 @@ __metadata: linkType: hard "eslint@npm:^8.29.0": - version: 8.52.0 - resolution: "eslint@npm:8.52.0" + version: 8.53.0 + resolution: "eslint@npm:8.53.0" dependencies: "@eslint-community/eslint-utils": ^4.2.0 "@eslint-community/regexpp": ^4.6.1 - "@eslint/eslintrc": ^2.1.2 - "@eslint/js": 8.52.0 + "@eslint/eslintrc": ^2.1.3 + "@eslint/js": 8.53.0 "@humanwhocodes/config-array": ^0.11.13 "@humanwhocodes/module-importer": ^1.0.1 "@nodelib/fs.walk": ^1.2.8 @@ -4769,7 +4769,7 @@ __metadata: text-table: ^0.2.0 bin: eslint: bin/eslint.js - checksum: fd22d1e9bd7090e31b00cbc7a3b98f3b76020a4c4641f987ae7d0c8f52e1b88c3b268bdfdabac2e1a93513e5d11339b718ff45cbff48a44c35d7e52feba510ed + checksum: 2da808655c7aa4b33f8970ba30d96b453c3071cc4d6cd60d367163430677e32ff186b65270816b662d29139283138bff81f28dddeb2e73265495245a316ed02c languageName: node linkType: hard @@ -4892,15 +4892,15 @@ __metadata: linkType: hard "fast-glob@npm:^3.2.9": - version: 3.3.1 - resolution: "fast-glob@npm:3.3.1" + version: 3.3.2 + resolution: "fast-glob@npm:3.3.2" dependencies: "@nodelib/fs.stat": ^2.0.2 "@nodelib/fs.walk": ^1.2.3 glob-parent: ^5.1.2 merge2: ^1.3.0 micromatch: ^4.0.4 - checksum: b6f3add6403e02cf3a798bfbb1183d0f6da2afd368f27456010c0bc1f9640aea308243d4cb2c0ab142f618276e65ecb8be1661d7c62a7b4e5ba774b9ce5432e5 + checksum: 900e4979f4dbc3313840078419245621259f349950411ca2fa445a2f9a1a6d98c3b5e7e0660c5ccd563aa61abe133a21765c6c0dec8e57da1ba71d8000b05ec1 languageName: node linkType: hard @@ -5946,9 +5946,9 @@ __metadata: linkType: hard "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": - version: 3.2.0 - resolution: "istanbul-lib-coverage@npm:3.2.0" - checksum: a2a545033b9d56da04a8571ed05c8120bf10e9bce01cf8633a3a2b0d1d83dff4ac4fe78d6d5673c27fc29b7f21a41d75f83a36be09f82a61c367b56aa73c1ff9 + version: 3.2.1 + resolution: "istanbul-lib-coverage@npm:3.2.1" + checksum: 382d5f698fed81de5c32a32d91848cba29df097bfce162f3cdd7fb66de7feeace9873d75c9d6bf3e34b1a4cda6be5bd819ec41c4b532c584dbff7c69db85448e languageName: node linkType: hard @@ -6859,6 +6859,7 @@ __metadata: "@lumino/signaling": ^2.0.0 "@lumino/widgets": ^2.0.0 "@material-ui/core": ^4.11.3 + "@rjsf/utils": ^5.13.2 "@types/file-saver": ^2.0.1 "@types/jest": ^29.5.1 "@types/jszip": ^3.4.1 @@ -6883,6 +6884,7 @@ __metadata: react: ^18.2.0 react-dom: ^18.2.0 rimraf: ^5.0.1 + semver: ^7.5.4 shx: ^0.3.3 source-map-loader: ^4.0.1 tree-finder: ^0.0.13 @@ -7356,11 +7358,11 @@ __metadata: linkType: hard "nanoid@npm:^3.3.6": - version: 3.3.6 - resolution: "nanoid@npm:3.3.6" + version: 3.3.7 + resolution: "nanoid@npm:3.3.7" bin: nanoid: bin/nanoid.cjs - checksum: 7d0eda657002738aa5206107bd0580aead6c95c460ef1bdd0b1a87a9c7ae6277ac2e9b945306aaa5b32c6dcb7feaf462d0f552e7f8b5718abfc6ead5c94a71b3 + checksum: d36c427e530713e4ac6567d488b489a36582ef89da1d6d4e3b87eded11eb10d7042a877958c6f104929809b2ab0bafa17652b076cdf84324aa75b30b722204f2 languageName: node linkType: hard @@ -7407,8 +7409,8 @@ __metadata: linkType: hard "node-gyp@npm:latest": - version: 10.0.0 - resolution: "node-gyp@npm:10.0.0" + version: 10.0.1 + resolution: "node-gyp@npm:10.0.1" dependencies: env-paths: ^2.2.0 exponential-backoff: ^3.1.1 @@ -7422,7 +7424,7 @@ __metadata: which: ^4.0.0 bin: node-gyp: bin/node-gyp.js - checksum: 65fa5d9f8ef03fa22c5f2d34da23435a63d3743400ca941a4394eb943cf340796456697a7797af1451606dbbeecb663be9328995dadc0b99e58dd583dc3a7a0f + checksum: 60a74e66d364903ce02049966303a57f898521d139860ac82744a5fdd9f7b7b3b61f75f284f3bfe6e6add3b8f1871ce305a1d41f775c7482de837b50c792223f languageName: node linkType: hard @@ -9416,9 +9418,9 @@ __metadata: linkType: hard "universalify@npm:^2.0.0": - version: 2.0.0 - resolution: "universalify@npm:2.0.0" - checksum: 2406a4edf4a8830aa6813278bab1f953a8e40f2f63a37873ffa9a3bc8f9745d06cc8e88f3572cb899b7e509013f7f6fcc3e37e8a6d914167a5381d8440518c44 + version: 2.0.1 + resolution: "universalify@npm:2.0.1" + checksum: ecd8469fe0db28e7de9e5289d32bd1b6ba8f7183db34f3bfc4ca53c49891c2d6aa05f3fb3936a81285a905cc509fb641a0c3fc131ec786167eff41236ae32e60 languageName: node linkType: hard From a2c17ee25777a5cc3ef8d89943945c773e4da3b6 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Wed, 8 Nov 2023 14:09:26 +0000 Subject: [PATCH 44/49] Lints and formatting --- js/src/commands.ts | 2 +- js/src/drag.ts | 48 ++++-------------------------------- js/src/settings.ts | 4 +-- js/tests/settings.test.ts | 16 ++++++------ jupyterfs/__init__.py | 1 + jupyterfs/auth.py | 22 +++++++++-------- jupyterfs/config.py | 23 +++++++++++------ jupyterfs/extension.py | 5 ++-- jupyterfs/fsmanager.py | 46 +++++++++++++++++++++++----------- jupyterfs/snippets.py | 5 ++-- jupyterfs/tests/test_auth.py | 18 ++++++++------ 11 files changed, 90 insertions(+), 100 deletions(-) diff --git a/js/src/commands.ts b/js/src/commands.ts index 0ad7ed54..7b811e61 100644 --- a/js/src/commands.ts +++ b/js/src/commands.ts @@ -271,7 +271,7 @@ export function createStaticCommands( path, }); } catch (e) { - void showErrorMessage("Could not create file", e); + void showErrorMessage("Could not create file", e as string); return; } target.invalidate(); diff --git a/js/src/drag.ts b/js/src/drag.ts index 9bc665f9..38fd4f09 100644 --- a/js/src/drag.ts +++ b/js/src/drag.ts @@ -67,7 +67,7 @@ import { } from "@lumino/coreutils"; import { - Drag + Drag, } from "@lumino/dragdrop"; @@ -133,11 +133,11 @@ export function findChild(parent: HTMLElement | HTMLElement[], node: HTMLElement): HTMLElement | null { // Work our way up the DOM to an element which has this node as parent const parentIsArray = Array.isArray(parent); - const isDirectChild = (child: HTMLElement): boolean => { + const isDirectChild = (element: HTMLElement): boolean => { if (parentIsArray) { - return (parent as HTMLElement[]).indexOf(child) > -1; + return parent.indexOf(element) > -1; } else { - return child.parentElement === parent; + return element.parentElement === parent; } }; let candidate: HTMLElement | null = node; @@ -517,7 +517,7 @@ abstract class DragDropWidgetBase extends DropWidget { this.addMimeData(handle, this.drag.mimeData); // Start the drag and remove the mousemove listener. - void this.drag.start(clientX, clientY).then(this.onDragComplete.bind(this)); + void this.drag.start(clientX, clientY).then(action => this.onDragComplete(action)); document.removeEventListener("mousemove", this, true); document.removeEventListener("mouseup", this, true); } @@ -872,41 +872,3 @@ namespace DragDropWidget { interface IOptions extends DragWidget.IOptions, DropWidget.IOptions { } } - - -export -abstract class FriendlyDragDrop extends DragDropWidget { - private static _counter = 0; - private static _groups: {[key: number]: FriendlyDragDrop[]} = {}; - - static makeGroup() { - const id = this._counter++; - FriendlyDragDrop._groups[id] = []; - return id; - } - - setFriendlyGroup(id: number) { - this._groupId = id; - FriendlyDragDrop._groups[id].push(this); - } - - addToFriendlyGroup(other: FriendlyDragDrop) { - other.setFriendlyGroup(this._groupId); - } - - get friends(): FriendlyDragDrop[] { - if (this._groupId === undefined) { - throw new Error("Uninitialized drag-drop group"); - } - return FriendlyDragDrop._groups[this._groupId]; - } - - private _groupId: number; - - protected validateSource(event: Drag.Event) { - if (this.acceptDropsFromExternalSource) { - return this.friends.indexOf(event.source) !== -1; - } - return super.validateSource(event); - } -} diff --git a/js/src/settings.ts b/js/src/settings.ts index 30d08d14..6d0d169b 100644 --- a/js/src/settings.ts +++ b/js/src/settings.ts @@ -49,9 +49,9 @@ export async function migrateSettings(settings: ISettingRegistry.ISettings): Pro /** * Ensure undefined string values from settings that are required are translated to empty strings - * @param settingsResoruce + * @param settingsResoruce * @returns A filled in setting object */ export function unpartialResource(settingsResource: IFSSettingsResource): IFSResource { - return {name: "", url: "", ...settingsResource}; + return { name: "", url: "", ...settingsResource }; } diff --git a/js/tests/settings.test.ts b/js/tests/settings.test.ts index 2092b54d..e54e7e83 100644 --- a/js/tests/settings.test.ts +++ b/js/tests/settings.test.ts @@ -5,13 +5,13 @@ import { IFSSettingsResource } from "../src/filesystem"; describe("unpartialResource", () => { - it("should replace non-existing fields with empty string", () => { + it("should replace non-existing fields with empty string", () => { - // blank resource missing name and url (like after pressing "add") - let resource: IFSSettingsResource = {auth: "ask", defaultWritable: true}; - let unpartialed = unpartialResource(resource); + // blank resource missing name and url (like after pressing "add") + const resource: IFSSettingsResource = { auth: "ask", defaultWritable: true }; + const unpartialed = unpartialResource(resource); - expect(unpartialed.name).toEqual("") - expect(unpartialed.url).toEqual("") - }) -}) \ No newline at end of file + expect(unpartialed.name).toEqual(""); + expect(unpartialed.url).toEqual(""); + }); +}); diff --git a/jupyterfs/__init__.py b/jupyterfs/__init__.py index cfbf588b..dbae97e3 100644 --- a/jupyterfs/__init__.py +++ b/jupyterfs/__init__.py @@ -20,6 +20,7 @@ def open_fs(fs_url, **kwargs): """Wrapper around fs.open_fs with {{variable}} substitution""" import fs from .auth import stdin_prompt + # substitute credential variables via `getpass` queries fs_url = stdin_prompt(fs_url) return fs.open_fs(fs_url, **kwargs) diff --git a/jupyterfs/auth.py b/jupyterfs/auth.py index 7806be08..a4e2b770 100644 --- a/jupyterfs/auth.py +++ b/jupyterfs/auth.py @@ -42,28 +42,30 @@ class DoubleBraceTemplate(_BaseTemplate): # Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, # 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; # All Rights Reserved - + def get_identifiers(self): ids = [] for mo in self.pattern.finditer(self.template): - named = mo.group('named') or mo.group('braced') + named = mo.group("named") or mo.group("braced") if named is not None and named not in ids: # add a named group only the first time it appears ids.append(named) - elif (named is None - and mo.group('invalid') is None - and mo.group('escaped') is None): + elif ( + named is None + and mo.group("invalid") is None + and mo.group("escaped") is None + ): # If all the groups are None, there must be # another group we're not expecting - raise ValueError('Unrecognized named group in pattern', - self.pattern) - return ids - + raise ValueError("Unrecognized named group in pattern", self.pattern) + return ids + setattr(DoubleBraceTemplate, "get_identifiers", get_identifiers) def stdin_prompt(url): from getpass import getpass + template = DoubleBraceTemplate(url) subs = {} for ident in template.get_identifiers(): @@ -74,7 +76,7 @@ def stdin_prompt(url): def substituteAsk(resource): if "tokenDict" in resource: url = DoubleBraceTemplate(resource["url"]).safe_substitute( - { k: urllib.parse.quote(v) for k, v in resource.pop("tokenDict").items() } + {k: urllib.parse.quote(v) for k, v in resource.pop("tokenDict").items()} ) else: url = resource["url"] diff --git a/jupyterfs/config.py b/jupyterfs/config.py index 93e3f125..be12ad1a 100644 --- a/jupyterfs/config.py +++ b/jupyterfs/config.py @@ -48,18 +48,25 @@ class JupyterFs(Configurable): surface_init_errors = Bool( default_value=False, config=True, - help=_i18n("whether to surface init errors to the client") + help=_i18n("whether to surface init errors to the client"), ) snippets = List( config=True, - per_key_traits=Dict({ - "label": Unicode(help="The designator to show to users"), - "caption": Unicode("", help="An optional, longer description to show to users"), - "pattern": Unicode("", help="A regular expression to match against the full URL of the entry, indicating if this snippet is valid for it"), - "template": Unicode(help="A template string to build up the snippet"), - }), + per_key_traits=Dict( + { + "label": Unicode(help="The designator to show to users"), + "caption": Unicode( + "", help="An optional, longer description to show to users" + ), + "pattern": Unicode( + "", + help="A regular expression to match against the full URL of the entry, indicating if this snippet is valid for it", + ), + "template": Unicode(help="A template string to build up the snippet"), + } + ), help=_i18n( "per entry snippets for how to use it, e.g. a snippet for how to open a file from a given resource" ), - ) \ No newline at end of file + ) diff --git a/jupyterfs/extension.py b/jupyterfs/extension.py index 8ca84695..9414a94b 100644 --- a/jupyterfs/extension.py +++ b/jupyterfs/extension.py @@ -55,8 +55,9 @@ def _load_jupyter_server_extension(serverapp): % url_path_join(base_url, resources_url) ) web_app.add_handlers( - host_pattern, [ + host_pattern, + [ (url_path_join(base_url, resources_url), MetaManagerHandler), (url_path_join(base_url, "jupyterfs/snippets"), SnippetsHandler), - ] + ], ) diff --git a/jupyterfs/fsmanager.py b/jupyterfs/fsmanager.py index e80acea5..7d3081e7 100644 --- a/jupyterfs/fsmanager.py +++ b/jupyterfs/fsmanager.py @@ -176,7 +176,9 @@ def _is_path_hidden(self, path, info): import os syspath = self._pyfilesystem_instance.getsyspath(path) - if os.path.exists(syspath) and not os.access(syspath, os.X_OK | os.R_OK): + if os.path.exists(syspath) and not os.access( + syspath, os.X_OK | os.R_OK + ): return True except ResourceNotFound: @@ -237,7 +239,7 @@ def exists(self, path): def _base_model(self, path, info): """ Build the common base of a contents model - + info (): FS Info object for file/dir at path -- used for values and reduces needed network calls """ try: @@ -290,7 +292,6 @@ def _base_model(self, path, info): except AttributeError: model["writable"] = False return model - def _dir_model(self, path, info, content=True): """Build a model for a directory @@ -301,24 +302,31 @@ def _dir_model(self, path, info, content=True): if not info.is_dir: raise web.HTTPError(404, four_o_four) - + elif not self.allow_hidden and self.is_hidden(path, info): self.log.debug("Refusing to serve hidden directory %r, via 404 Error", path) raise web.HTTPError(404, four_o_four) - model = self._base_model(path, info) model["type"] = "directory" model["size"] = None if content: model["content"] = contents = [] - for dir_entry in self._pyfilesystem_instance.scandir(path, namespaces=("basic", "access", "details", "stat")): + for dir_entry in self._pyfilesystem_instance.scandir( + path, namespaces=("basic", "access", "details", "stat") + ): try: if self.should_list(dir_entry.name): - if self.allow_hidden or not self._is_path_hidden(dir_entry.make_path(path), dir_entry): + if self.allow_hidden or not self._is_path_hidden( + dir_entry.make_path(path), dir_entry + ): contents.append( - self.get(path="%s/%s" % (path, dir_entry.name), content=False, info=dir_entry) + self.get( + path="%s/%s" % (path, dir_entry.name), + content=False, + info=dir_entry, + ) ) except PermissionDenied: pass # Don't provide clues about protected files @@ -327,7 +335,9 @@ def _dir_model(self, path, info, content=True): # us from listing other entries pass except Exception as e: - self.log.warning("Error stat-ing %s: %s", dir_entry.make_path(path), e) + self.log.warning( + "Error stat-ing %s: %s", dir_entry.make_path(path), e + ) model["format"] = "json" return model @@ -374,7 +384,7 @@ def _file_model(self, path, info, content=True, format=None): If 'text', the contents will be decoded as UTF-8. If 'base64', the raw bytes contents will be encoded as base64. If not specified, try to decode as UTF-8, and fall back to base64 - + info (): FS Info object for file at path """ model = self._base_model(path, info) @@ -418,9 +428,13 @@ def get(self, path, content=True, type=None, format=None, info=None): Args: path (str): the API path that describes the relative path for the target content (bool): Whether to include the contents in the reply - type (str): The requested type - 'file', 'notebook', or 'directory'. Will raise HTTPError 400 if the content doesn't match. - format (str): The requested format for file contents. 'text' or 'base64'. Ignored if this returns a notebook or directory model. - info (fs Info object): Optional FS Info. If present, it needs to include the following namespaces: "basic", "stat", "access", "details". Including it can avoid extraneous networkcalls. + type (str): The requested type - 'file', 'notebook', or 'directory'. + Will raise HTTPError 400 if the content doesn't match. + format (str): + The requested format for file contents. 'text' or 'base64'. Ignored if this returns a notebook or directory model. + info (fs Info object): + Optional FS Info. If present, it needs to include the following namespaces: "basic", "stat", "access", "details". + Including it can avoid extraneous networkcalls. Returns model (dict): the contents model. If content=True, returns the contents of the file or directory as well. """ @@ -429,8 +443,10 @@ def get(self, path, content=True, type=None, format=None, info=None): # gather info - by doing here can minimise further network requests from underlying fs functions if not info: try: - info = self._pyfilesystem_instance.getinfo(path, namespaces=("basic", "stat", "access", "details")) - except: + info = self._pyfilesystem_instance.getinfo( + path, namespaces=("basic", "stat", "access", "details") + ) + except Exception: raise web.HTTPError(404, "No such file or directory: %s" % path) if info.is_dir: diff --git a/jupyterfs/snippets.py b/jupyterfs/snippets.py index 9e4ab435..873b15a8 100644 --- a/jupyterfs/snippets.py +++ b/jupyterfs/snippets.py @@ -11,6 +11,7 @@ from .config import JupyterFs as JupyterFsConfig + class SnippetsHandler(APIHandler): _jupyterfsConfig = None @@ -25,6 +26,4 @@ def fsconfig(self): @web.authenticated def get(self): """Get the server-side configured snippets""" - self.write({ - "snippets": self.fsconfig.snippets - }) + self.write({"snippets": self.fsconfig.snippets}) diff --git a/jupyterfs/tests/test_auth.py b/jupyterfs/tests/test_auth.py index c95766f5..2f120e1d 100644 --- a/jupyterfs/tests/test_auth.py +++ b/jupyterfs/tests/test_auth.py @@ -2,7 +2,7 @@ import unittest.mock from fs.opener.parse import parse_fs_url -from jupyterfs.auth import substituteAsk, substituteEnv, substituteNone +from jupyterfs.auth import substituteAsk, substituteEnv import pytest @@ -18,10 +18,10 @@ token_dicts = [ {}, - {'prefix_username': 'username'}, - {'pword': 'pword'}, - {'prefix_username': 'username', 'pword': 'pword'}, - {'prefix_username': 'user:na@me', 'pword': 'pwo@r:d'}, + {"prefix_username": "username"}, + {"pword": "pword"}, + {"prefix_username": "username", "pword": "pword"}, + {"prefix_username": "user:na@me", "pword": "pwo@r:d"}, ] @@ -34,7 +34,8 @@ def _url_tokens_pair(): @pytest.fixture(params=_url_tokens_pair()) def any_url_token_ask_resource(request): url, token_dict = request.param - return dict(url=url, tokenDict = token_dict) + return dict(url=url, tokenDict=token_dict) + @pytest.fixture(params=_url_tokens_pair()) def any_url_token_env_resource(request): @@ -46,13 +47,14 @@ def any_url_token_env_resource(request): def test_ensure_ask_validates(any_url_token_ask_resource): url, missing = substituteAsk(any_url_token_ask_resource) if missing: - return pytest.xfail(f'tokens are not sufficient, missing: {missing}') + return pytest.xfail(f"tokens are not sufficient, missing: {missing}") # simply ensure it doesn't throw: parse_fs_url(url) + def test_ensure_env_validates(any_url_token_env_resource): url, missing = substituteEnv(any_url_token_env_resource) if missing: - return pytest.xfail(f'tokens are not sufficient, missing: {missing}') + return pytest.xfail(f"tokens are not sufficient, missing: {missing}") # simply ensure it doesn't throw: parse_fs_url(url) From 41c79989ccfbec2091fa680eae5d6049def13a95 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Wed, 8 Nov 2023 14:22:16 +0000 Subject: [PATCH 45/49] Update test_metamanager.py --- jupyterfs/tests/test_metamanager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterfs/tests/test_metamanager.py b/jupyterfs/tests/test_metamanager.py index e26b24b0..cd16f1a2 100644 --- a/jupyterfs/tests/test_metamanager.py +++ b/jupyterfs/tests/test_metamanager.py @@ -120,7 +120,7 @@ async def test_resource_validators(tmp_path, jp_fetch, jp_server_config): }, ] ) - names = set(map(lambda r: r["name"], resources)) + names = {r["name"] for r in resources if r["init"]} assert names == {"valid-1", "valid-2"} From 21d990f3fa19ac1ca7a9bc95f9eb59b08c5e60ea Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Wed, 8 Nov 2023 14:53:18 +0000 Subject: [PATCH 46/49] Fix schema pattern --- js/schema/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/schema/plugin.json b/js/schema/plugin.json index ea4037cc..bd48f7df 100644 --- a/js/schema/plugin.json +++ b/js/schema/plugin.json @@ -46,7 +46,7 @@ "url": { "description": "A url pointing to an fs resource, as per the PyFilesystem fsurl specification", "type": "string", - "pattern": "^.+?:\/\/((([^:]*):(|\\{[^@]+\\})@.*)|[^@]*)$" + "pattern": "^.+?:\/\/((([^:]*):(|\\{\\{[^@]+\\}\\})@.*)|[^@]*)$" }, "preferred_dir": { "description": "Directory to be first opened (e.g., myDir/mySubdir)", From f2976bdb46ed60bafe37ed04b1bff36604350ebf Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Wed, 8 Nov 2023 14:53:46 +0000 Subject: [PATCH 47/49] Add test for open_fs / stdin_prompt --- jupyterfs/tests/test_init.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/jupyterfs/tests/test_init.py b/jupyterfs/tests/test_init.py index 9b0a6138..38a12f57 100644 --- a/jupyterfs/tests/test_init.py +++ b/jupyterfs/tests/test_init.py @@ -6,10 +6,35 @@ # the Apache License 2.0. The full license can be found in the LICENSE file. # -# for Coverage +from jupyterfs import _jupyter_labextension_paths, open_fs from jupyterfs.extension import _jupyter_server_extension_points +from unittest.mock import patch + class TestInit: + # for Coverage def test__jupyter_server_extension_paths(self): assert _jupyter_server_extension_points() == [{"module": "jupyterfs.extension"}] + + # for Coverage + def test__jupyter_labextension_paths(self): + assert _jupyter_labextension_paths() == [ + { + "src": "labextension", + "dest": "jupyter-fs", + } + ] + + @patch("fs.open_fs") + @patch("getpass.getpass", return_value="test return getpass <>/|") + def test_open_fs(self, mock_getpass, mock_fs_open_fs): + open_fs("osfs://foo/bar.txt") + mock_getpass.assert_not_called() + mock_fs_open_fs.assert_called_with("osfs://foo/bar.txt") + + open_fs("osfs://{{foo}}/bar.txt") + mock_getpass.assert_called_with("Enter value for 'foo': ") + mock_fs_open_fs.assert_called_with( + "osfs://test%20return%20getpass%20%3C%3E/%7C/bar.txt" + ) From 323593b221348ca6c39b61a4fa33948b7eb0acf6 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Wed, 8 Nov 2023 15:24:27 +0000 Subject: [PATCH 48/49] Do not ask for tokens if none missing --- js/src/auth.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/src/auth.tsx b/js/src/auth.tsx index ca3e7f51..bcb09940 100644 --- a/js/src/auth.tsx +++ b/js/src/auth.tsx @@ -55,8 +55,8 @@ function tokensFromUrl(url: string): string[] { return new DoubleBraceTemplate(url).tokens(); } -function _askRequired(spec: IFSResource) { - return spec.auth === "ask" && !spec.init; +function _askRequired(spec: IFSResource): boolean { + return spec.auth === "ask" && !spec.init && !!(spec.missingTokens?.length); } export function askRequired(specs: IFSResource[]) { From 4839b85291fd53b4f87367a7b3669ffebe0b0361 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske <510760+vidartf@users.noreply.github.com> Date: Wed, 8 Nov 2023 15:26:06 +0000 Subject: [PATCH 49/49] Allow hard-coding passwords While it is normally a bad idea to store credentials in plain-text in settings, using a fixed schema pattern to enforce it is probably nor correct here. It would need to be made dependent on a setting to allow/disallow for this to make sense for a general audience. --- js/schema/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/schema/plugin.json b/js/schema/plugin.json index bd48f7df..fa480969 100644 --- a/js/schema/plugin.json +++ b/js/schema/plugin.json @@ -46,7 +46,7 @@ "url": { "description": "A url pointing to an fs resource, as per the PyFilesystem fsurl specification", "type": "string", - "pattern": "^.+?:\/\/((([^:]*):(|\\{\\{[^@]+\\}\\})@.*)|[^@]*)$" + "pattern": "^.+?:\/\/([^:]*:.*@.*|[^@]*)$" }, "preferred_dir": { "description": "Directory to be first opened (e.g., myDir/mySubdir)",