diff --git a/schema/widget.json b/schema/widget.json index c450b97..9928cb2 100644 --- a/schema/widget.json +++ b/schema/widget.json @@ -19,6 +19,67 @@ "title": "'@jupyter/drives", "description": "jupyter-drives settings.", "type": "object", - "properties": {}, - "additionalProperties": false + "jupyter.lab.transform": true, + "properties": { + "toolbar": { + "title": "File browser toolbar items", + "description": "Note: To disable a toolbar item,\ncopy it to User Preferences and add the\n\"disabled\" key. The following example will disable the uploader button:\n{\n \"toolbar\": [\n {\n \"name\": \"uploader\",\n \"disabled\": true\n }\n ]\n}\n\nToolbar description:", + "items": { + "$ref": "#/definitions/toolbarItem" + }, + "type": "array", + "default": [] + } + }, + "additionalProperties": false, + "definitions": { + "toolbarItem": { + "properties": { + "name": { + "title": "Unique name", + "type": "string" + }, + "args": { + "title": "Command arguments", + "type": "object" + }, + "command": { + "title": "Command id", + "type": "string", + "default": "" + }, + "disabled": { + "title": "Whether the item is ignored or not", + "type": "boolean", + "default": false + }, + "icon": { + "title": "Item icon id", + "description": "If defined, it will override the command icon", + "type": "string" + }, + "label": { + "title": "Item label", + "description": "If defined, it will override the command label", + "type": "string" + }, + "caption": { + "title": "Item caption", + "description": "If defined, it will override the command caption", + "type": "string" + }, + "type": { + "title": "Item type", + "type": "string", + "enum": ["command", "spacer"] + }, + "rank": { + "title": "Item rank", + "type": "number", + "minimum": 0, + "default": 50 + } + } + } + } } diff --git a/src/contents.ts b/src/contents.ts new file mode 100644 index 0000000..62bdac0 --- /dev/null +++ b/src/contents.ts @@ -0,0 +1,380 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Signal, ISignal } from '@lumino/signaling'; +import { Contents, ServerConnection } from '@jupyterlab/services'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; + +const drive1Contents: Contents.IModel = { + name: 'Drive1', + path: 'Drive1', + last_modified: '2023-10-31T12:39:42.832781Z', + created: '2023-10-31T12:39:42.832781Z', + content: [ + { + name: 'voila2.ipynb', + path: 'Drive1/voila2.ipynb', + last_modified: '2022-10-12T21:33:04.798185Z', + created: '2022-11-09T12:37:21.020396Z', + content: null, + format: null, + mimetype: null, + size: 5377, + writable: true, + type: 'notebook' + }, + { + name: 'Untitled.ipynb', + path: 'Drive1/Untitled.ipynb', + last_modified: '2023-10-25T08:20:09.395167Z', + created: '2023-10-25T08:20:09.395167Z', + content: null, + format: null, + mimetype: null, + size: 4772, + writable: true, + type: 'notebook' + }, + { + name: 'voila.ipynb', + path: 'Drive1/voila.ipynb', + last_modified: '2023-10-31T09:43:05.235448Z', + created: '2023-10-31T09:43:05.235448Z', + content: null, + format: null, + mimetype: null, + size: 2627, + writable: true, + type: 'notebook' + }, + { + name: 'b.ipynb', + path: 'Drive1/b.ipynb', + last_modified: '2023-10-26T15:21:06.152419Z', + created: '2023-10-26T15:21:06.152419Z', + content: null, + format: null, + mimetype: null, + size: 1198, + writable: true, + type: 'notebook' + }, + { + name: '_output', + path: '_output', + last_modified: '2023-10-31T12:39:41.222780Z', + created: '2023-10-31T12:39:41.222780Z', + content: null, + format: null, + mimetype: null, + size: null, + writable: true, + type: 'directory' + }, + { + name: 'a.ipynb', + path: 'Drive1/a.ipynb', + last_modified: '2023-10-25T10:07:09.141206Z', + created: '2023-10-25T10:07:09.141206Z', + content: null, + format: null, + mimetype: null, + size: 8014, + writable: true, + type: 'notebook' + }, + { + name: 'environment.yml', + path: 'Drive1/environment.yml', + last_modified: '2023-10-31T09:33:57.415583Z', + created: '2023-10-31T09:33:57.415583Z', + content: null, + format: null, + mimetype: null, + size: 153, + writable: true, + type: 'file' + } + ], + format: 'json', + mimetype: '', + size: undefined, + writable: true, + type: 'directory' +}; + +/** + * A Contents.IDrive implementation that serves as a read-only + * view onto the drive repositories. + */ + +export class Drive implements Contents.IDrive { + /** + * Construct a new drive object. + * + * @param options - The options used to initialize the object. + */ + constructor(registry: DocumentRegistry) { + this._serverSettings = ServerConnection.makeSettings(); + } + /** + * The Drive base URL + */ + get baseUrl(): string { + return this._baseUrl; + } + + /** + * The Drive base URL is set by the settingsRegistry change hook + */ + set baseUrl(url: string) { + this._baseUrl = url; + } + /** + * The Drive name getter + */ + get name(): string { + return this._name; + } + + /** + * The Drive name setter */ + set name(name: string) { + this._name = name; + } + + /** + * The Drive provider getter + */ + get provider(): string { + return this._provider; + } + + /** + * The Drive provider setter */ + set provider(name: string) { + this._provider = name; + } + + /** + * The Drive status getter (if it is active or not) + */ + get status(): string { + return this._status; + } + + /** + * The Drive status setter */ + set status(status: string) { + this._status = status; + } + + /** + * The Drive region getter + */ + get region(): string { + return this._region; + } + + /** + * The Drive region setter */ + set region(region: string) { + this._region = region; + } + + /** + * The Drive creationDate getter + */ + get creationDate(): string { + return this._creationDate; + } + + /** + * The Drive region setter */ + set creationDate(date: string) { + this._creationDate = date; + } + + /** + * Settings for the notebook server. + */ + get serverSettings(): ServerConnection.ISettings { + return this._serverSettings; + } + + /** + * A signal emitted when a file operation takes place. + */ + get fileChanged(): ISignal { + return this._fileChanged; + } + + /** + * Test whether the manager has been disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Dispose of the resources held by the manager. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._isDisposed = true; + Signal.clearData(this); + } + + /** + * Get an encoded download url given a file path. + * + * @param path - An absolute POSIX file path on the server. + * + * #### Notes + * It is expected that the path contains no relative paths, + * use [[ContentsManager.getAbsolutePath]] to get an absolute + * path if necessary. + */ + getDownloadUrl(path: string): Promise { + // Parse the path into user/repo/path + console.log('Path is:', path); + return Promise.reject('Empty getDownloadUrl method'); + } + + async get( + path: string, + options?: Contents.IFetchOptions + ): Promise { + return drive1Contents; + } + + /** + * Create a new untitled file or directory in the specified directory path. + * + * @param options: The options used to create the file. + * + * @returns A promise which resolves with the created file content when the + * file is created. + */ + newUntitled(options: Contents.ICreateOptions = {}): Promise { + return Promise.reject('Repository is read only'); + } + + /** + * Delete a file. + * + * @param path - The path to the file. + * + * @returns A promise which resolves when the file is deleted. + */ + delete(path: string): Promise { + return Promise.reject('Repository is read only'); + } + + /** + * Rename a file or directory. + * + * @param path - The original file path. + * + * @param newPath - The new file path. + * + * @returns A promise which resolves with the new file contents model when + * the file is renamed. + */ + rename(path: string, newPath: string): Promise { + return Promise.reject('Repository is read only'); + } + + /** + * Save a file. + * + * @param path - The desired file path. + * + * @param options - Optional overrides to the model. + * + * @returns A promise which resolves with the file content model when the + * file is saved. + */ + save( + path: string, + options: Partial + ): Promise { + return Promise.reject('Repository is read only'); + } + + /** + * Copy a file into a given directory. + * + * @param path - The original file path. + * + * @param toDir - The destination directory path. + * + * @returns A promise which resolves with the new contents model when the + * file is copied. + */ + copy(fromFile: string, toDir: string): Promise { + return Promise.reject('Repository is read only'); + } + + /** + * Create a checkpoint for a file. + * + * @param path - The path of the file. + * + * @returns A promise which resolves with the new checkpoint model when the + * checkpoint is created. + */ + createCheckpoint(path: string): Promise { + return Promise.reject('Repository is read only'); + } + + /** + * List available checkpoints for a file. + * + * @param path - The path of the file. + * + * @returns A promise which resolves with a list of checkpoint models for + * the file. + */ + listCheckpoints(path: string): Promise { + return Promise.resolve([]); + } + + /** + * Restore a file to a known checkpoint state. + * + * @param path - The path of the file. + * + * @param checkpointID - The id of the checkpoint to restore. + * + * @returns A promise which resolves when the checkpoint is restored. + */ + restoreCheckpoint(path: string, checkpointID: string): Promise { + return Promise.reject('Repository is read only'); + } + + /** + * Delete a checkpoint for a file. + * + * @param path - The path of the file. + * + * @param checkpointID - The id of the checkpoint to delete. + * + * @returns A promise which resolves when the checkpoint is deleted. + */ + deleteCheckpoint(path: string, checkpointID: string): Promise { + return Promise.reject('Read only'); + } + + private _serverSettings: ServerConnection.ISettings; + private _name: string = ''; + private _provider: string = ''; + private _baseUrl: string = ''; + private _status: string = 'active' || 'inactive'; + private _region: string = ''; + private _creationDate: string = ''; + private _fileChanged = new Signal(this); + private _isDisposed: boolean = false; +} diff --git a/src/crumbslayout.ts b/src/crumbslayout.ts new file mode 100644 index 0000000..6e70ed8 --- /dev/null +++ b/src/crumbslayout.ts @@ -0,0 +1,253 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Message, MessageLoop } from '@lumino/messaging'; +import { + AccordionLayout, + AccordionPanel, + Title, + Widget +} from '@lumino/widgets'; +import { caretDownIcon } from '@jupyterlab/ui-components'; +import { BreadCrumbs } from '@jupyterlab/filebrowser'; +import { DriveBrowser } from './drivebrowser'; + +/** + * Accordion panel layout that adds a breadcrumb in widget title if present. + */ +export class BreadCrumbsLayout extends AccordionLayout { + /** + * Insert a widget into the layout at the specified index. + * + * @param index - The index at which to insert the widget. + * + * @param widget - The widget to insert into the layout. + * + * #### Notes + * The index will be clamped to the bounds of the widgets. + * + * If the widget is already added to the layout, it will be moved. + * + * #### Undefined Behavior + * An `index` which is non-integral. + */ + insertWidget(index: number, widget: DriveBrowser): void { + if (widget.breadcrumbs) { + this._breadcrumbs.set(widget, widget.breadcrumbs); + widget.breadcrumbs.addClass('jp-AccordionPanel-breadcrumbs'); + } + super.insertWidget(index, widget); + } + + /** + * Remove the widget at a given index from the layout. + * + * @param index - The index of the widget to remove. + * + * #### Notes + * A widget is automatically removed from the layout when its `parent` + * is set to `null`. This method should only be invoked directly when + * removing a widget from a layout which has yet to be installed on a + * parent widget. + * + * This method does *not* modify the widget's `parent`. + * + * #### Undefined Behavior + * An `index` which is non-integral. + */ + removeWidgetAt(index: number): void { + const widget = this.widgets[index]; + super.removeWidgetAt(index); + // Remove the breadcrumb after the widget has `removeWidgetAt` will call `detachWidget` + if (widget && this._breadcrumbs.has(widget)) { + this._breadcrumbs.delete(widget); + } + } + + /** + * Attach a widget to the parent's DOM node. + * + * @param index - The current index of the widget in the layout. + * + * @param widget - The widget to attach to the parent. + */ + protected attachWidget(index: number, widget: Widget): void { + super.attachWidget(index, widget); + + const breadcrumb = this._breadcrumbs.get(widget); + if (breadcrumb) { + // Send a `'before-attach'` message if the parent is attached. + if (this.parent!.isAttached) { + MessageLoop.sendMessage(breadcrumb, Widget.Msg.BeforeAttach); + } + + // Insert the breadcrumb in the title node. + this.titles[index].appendChild(breadcrumb.node); + + // Send an `'after-attach'` message if the parent is attached. + if (this.parent!.isAttached) { + MessageLoop.sendMessage(breadcrumb, Widget.Msg.AfterAttach); + } + } + } + + /** + * Detach a widget from the parent's DOM node. + * + * @param index - The previous index of the widget in the layout. + * + * @param widget - The widget to detach from the parent. + */ + protected detachWidget(index: number, widget: Widget): void { + const breadcrumb = this._breadcrumbs.get(widget); + if (breadcrumb) { + // Send a `'before-detach'` message if the parent is attached. + if (this.parent!.isAttached) { + MessageLoop.sendMessage(breadcrumb, Widget.Msg.BeforeDetach); + } + + // Remove the breadcrumb in the title node. + this.titles[index].removeChild(breadcrumb.node); + + // Send an `'after-detach'` message if the parent is attached. + if (this.parent!.isAttached) { + MessageLoop.sendMessage(breadcrumb, Widget.Msg.AfterDetach); + } + } + + super.detachWidget(index, widget); + } + + /** + * A message handler invoked on a `'before-attach'` message. + * + * #### Notes + * The default implementation of this method forwards the message + * to all widgets. It assumes all widget nodes are attached to the + * parent widget node. + * + * This may be reimplemented by subclasses as needed. + */ + protected onBeforeAttach(msg: Message): void { + this.notifyBreadcrumbs(msg); + super.onBeforeAttach(msg); + } + + /** + * A message handler invoked on an `'after-attach'` message. + * + * #### Notes + * The default implementation of this method forwards the message + * to all widgets. It assumes all widget nodes are attached to the + * parent widget node. + * + * This may be reimplemented by subclasses as needed. + */ + protected onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + this.notifyBreadcrumbs(msg); + } + + /** + * A message handler invoked on a `'before-detach'` message. + * + * #### Notes + * The default implementation of this method forwards the message + * to all widgets. It assumes all widget nodes are attached to the + * parent widget node. + * + * This may be reimplemented by subclasses as needed. + */ + protected onBeforeDetach(msg: Message): void { + this.notifyBreadcrumbs(msg); + super.onBeforeDetach(msg); + } + + /** + * A message handler invoked on an `'after-detach'` message. + * + * #### Notes + * The default implementation of this method forwards the message + * to all widgets. It assumes all widget nodes are attached to the + * parent widget node. + * + * This may be reimplemented by subclasses as needed. + */ + protected onAfterDetach(msg: Message): void { + super.onAfterDetach(msg); + this.notifyBreadcrumbs(msg); + } + + private notifyBreadcrumbs(msg: Message): void { + this.widgets.forEach(widget => { + const breadcrumb = this._breadcrumbs.get(widget); + if (breadcrumb) { + breadcrumb.processMessage(msg); + } + }); + } + + protected _breadcrumbs = new WeakMap(); +} + +export namespace BreadCrumbsLayout { + /** + * Custom renderer for the SidePanel + */ + export class Renderer extends AccordionPanel.Renderer { + /** + * Render the collapse indicator for a section title. + * + * @param data - The data to use for rendering the section title. + * + * @returns A element representing the collapse indicator. + */ + createCollapseIcon(data: Title): HTMLElement { + const iconDiv = document.createElement('div'); + caretDownIcon.element({ + container: iconDiv + }); + return iconDiv; + } + + /** + * Render the element for a section title. + * + * @param data - The data to use for rendering the section title. + * + * @returns A element representing the section title. + */ + createSectionTitle(data: Title): HTMLElement { + const handle = super.createSectionTitle(data); + handle.classList.add('jp-AccordionPanel-title'); + return handle; + } + } + + export const defaultRenderer = new Renderer(); + + /** + * Create an accordion layout for accordion panel with breadcrumb in the title. + * + * @param options Panel options + * @returns Panel layout + * + * #### Note + * + * Default titleSpace is 29 px (default var(--jp-private-toolbar-height) - but not styled) + */ + export function createLayout( + options: AccordionPanel.IOptions + ): AccordionLayout { + return ( + options.layout || + new BreadCrumbsLayout({ + renderer: options.renderer || defaultRenderer, + orientation: options.orientation, + alignment: options.alignment, + spacing: options.spacing, + titleSpace: options.titleSpace ?? 29 + }) + ); + } +} diff --git a/src/drivebrowser.ts b/src/drivebrowser.ts new file mode 100644 index 0000000..01ca064 --- /dev/null +++ b/src/drivebrowser.ts @@ -0,0 +1,72 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + BreadCrumbs, + FilterFileBrowserModel, + DirListing +} from '@jupyterlab/filebrowser'; +import { ITranslator } from '@jupyterlab/translation'; +/** + * The class name added to the filebrowser crumbs node. + */ +const CRUMBS_CLASS = 'jp-FileBrowser-crumbs'; + +export class DriveBrowser extends DirListing { + constructor(options: DriveBrowser.IOptions) { + super({ + model: options.model, + translator: options.translator, + renderer: options.renderer + }); + + this.title.label = options.driveName; + this._breadcrumbs = new BreadCrumbs({ + model: options.model, + translator: options.translator + }); + this._breadcrumbs.addClass(CRUMBS_CLASS); + } + + get breadcrumbs(): BreadCrumbs { + return this._breadcrumbs; + } + + private _breadcrumbs: BreadCrumbs; +} + +export namespace DriveBrowser { + /** + * An options object for initializing DrivesListing widget. + */ + export interface IOptions { + /** + * A file browser model instance. + */ + model: FilterFileBrowserModel; + + /** + * A renderer for file items. + * + * The default is a shared `Renderer` instance. + */ + renderer?: DirListing.IRenderer; + + /** + * A language translator. + */ + translator?: ITranslator; + + /** + *Breadcrumbs for the drive . + */ + + breadCrumbs: BreadCrumbs; + + /** + *Name of the drive . + */ + + driveName: string; + } +} diff --git a/src/drivelistmanager.tsx b/src/drivelistmanager.tsx index 66258c3..16560ea 100644 --- a/src/drivelistmanager.tsx +++ b/src/drivelistmanager.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +//import { requestAPI } from './handler'; import { VDomModel, VDomRenderer } from '@jupyterlab/ui-components'; import { Button, @@ -8,15 +9,13 @@ import { Search } from '@jupyter/react-components'; import { useState } from 'react'; +import { Drive } from './contents'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; interface IProps { model: DriveListModel; + docRegistry: DocumentRegistry; } -export interface IDrive { - name: string; - url: string; -} - export interface IDriveInputProps { isName: boolean; value: string; @@ -43,6 +42,7 @@ export function DriveInputComponent(props: IDriveInputProps) { ); } + interface ISearchListProps { isName: boolean; value: string; @@ -83,7 +83,7 @@ export function DriveSearchListComponent(props: ISearchListProps) { ); } interface IDriveDataGridProps { - drives: IDrive[]; + drives: Drive[]; } export function DriveDataGridComponent(props: IDriveDataGridProps) { @@ -105,7 +105,7 @@ export function DriveDataGridComponent(props: IDriveDataGridProps) { {item.name} - {item.url} + {item.baseUrl} ))} @@ -129,7 +129,7 @@ export function DriveListManagerComponent(props: IProps) { } const [nameFilteredList, setNameFilteredList] = useState(nameList); - const isDriveAlreadySelected = (pickedDrive: IDrive, driveList: IDrive[]) => { + const isDriveAlreadySelected = (pickedDrive: Drive, driveList: Drive[]) => { const isbyNameIncluded: boolean[] = []; const isbyUrlIncluded: boolean[] = []; let isIncluded: boolean = false; @@ -139,7 +139,7 @@ export function DriveListManagerComponent(props: IProps) { } else { isbyNameIncluded.push(false); } - if (pickedDrive.url !== '' && pickedDrive.url === item.url) { + if (pickedDrive.baseUrl !== '' && pickedDrive.baseUrl === item.baseUrl) { isbyUrlIncluded.push(true); } else { isbyUrlIncluded.push(false); @@ -155,15 +155,20 @@ export function DriveListManagerComponent(props: IProps) { const updateSelectedDrives = (item: string, isName: boolean) => { updatedSelectedDrives = [...props.model.selectedDrives]; - let pickedDrive: IDrive; - if (isName) { - pickedDrive = { name: item, url: '' }; - } else { - if (item !== driveUrl) { - setDriveUrl(item); + let pickedDrive = new Drive(props.docRegistry); + + props.model.availableDrives.forEach(drive => { + if (isName) { + if (item === drive.name) { + pickedDrive = drive; + } + } else { + if (item !== driveUrl) { + setDriveUrl(item); + } + pickedDrive.baseUrl = driveUrl; } - pickedDrive = { name: '', url: driveUrl }; - } + }); const checkDrive = isDriveAlreadySelected( pickedDrive, @@ -172,11 +177,12 @@ export function DriveListManagerComponent(props: IProps) { if (checkDrive === false) { updatedSelectedDrives.push(pickedDrive); } else { - console.log('The selected drive is already in the list'); + console.warn('The selected drive is already in the list'); } setSelectedDrives(updatedSelectedDrives); props.model.setSelectedDrives(updatedSelectedDrives); + props.model.stateChanged.emit(); }; const getValue = (event: any) => { @@ -242,30 +248,56 @@ export function DriveListManagerComponent(props: IProps) { } export class DriveListModel extends VDomModel { - public availableDrives: IDrive[]; - public selectedDrives: IDrive[]; + public availableDrives: Drive[]; + public selectedDrives: Drive[]; - constructor(availableDrives: IDrive[], selectedDrives: IDrive[]) { + constructor(availableDrives: Drive[], selectedDrives: Drive[]) { super(); this.availableDrives = availableDrives; this.selectedDrives = selectedDrives; } - setSelectedDrives(selectedDrives: IDrive[]) { + setSelectedDrives(selectedDrives: Drive[]) { this.selectedDrives = selectedDrives; } + async sendConnectionRequest(selectedDrives: Drive[]): Promise { + console.log( + 'Sending a request to connect to drive ', + selectedDrives[selectedDrives.length - 1].name + ); + const response = true; + /*requestAPI('send_connectionRequest', { + method: 'POST' + }) + .then(data => { + console.log('data:', data); + return data; + }) + .catch(reason => { + console.error( + `The jupyter_drive server extension appears to be missing.\n${reason}` + ); + return; + });*/ + return response; + } } export class DriveListView extends VDomRenderer { - constructor(model: DriveListModel) { + constructor(model: DriveListModel, docRegistry: DocumentRegistry) { super(model); this.model = model; + this.docRegistry = docRegistry; } render() { return ( <> - + ); } + private docRegistry: DocumentRegistry; } diff --git a/src/index.ts b/src/index.ts index 7b29767..0cfd783 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,38 @@ import { + ILayoutRestorer, JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { requestAPI } from './handler'; -import { IFileBrowserFactory } from '@jupyterlab/filebrowser'; import { ITranslator } from '@jupyterlab/translation'; import { addJupyterLabThemeChangeListener } from '@jupyter/web-components'; import { Dialog, showDialog } from '@jupyterlab/apputils'; -import { DriveListModel, DriveListView, IDrive } from './drivelistmanager'; +import { DriveListModel, DriveListView } from './drivelistmanager'; import { DriveIcon } from './icons'; +import { IDocumentManager } from '@jupyterlab/docmanager'; +import { Drive } from './contents'; +import { MultiDrivesFileBrowser } from './multidrivesbrowser'; +import { BreadCrumbs, FilterFileBrowserModel } from '@jupyterlab/filebrowser'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { + createToolbarFactory, + IToolbarWidgetRegistry, + setToolbar +} from '@jupyterlab/apputils'; +import { DriveBrowser } from './drivebrowser'; + +const FILE_BROWSER_FACTORY = 'FileBrowser'; +const FILE_BROWSER_PLUGIN_ID = '@jupyter/drives:widget'; namespace CommandIDs { export const openDrivesDialog = 'drives:open-drives-dialog'; + export const openPath = 'filebrowser:open-path'; } /** * Initialization data for the @jupyter/drives extension. */ -const plugin: JupyterFrontEndPlugin = { +/*const plugin: JupyterFrontEndPlugin = { id: '@jupyter/drives:plugin', description: 'A Jupyter extension to support drives in the backend.', autoStart: true, @@ -35,110 +49,187 @@ const plugin: JupyterFrontEndPlugin = { ); }); } -}; - -const openDriveDialogPlugin: JupyterFrontEndPlugin = { - id: '@jupyter/drives:widget', +};*/ +const AddDrivesPlugin: JupyterFrontEndPlugin = { + id: '@jupyter/drives:add-drives', description: 'Open a dialog to select drives to be added in the filebrowser.', - requires: [IFileBrowserFactory, ITranslator], + requires: [ + IDocumentManager, + IToolbarWidgetRegistry, + ITranslator, + ILayoutRestorer, + ISettingRegistry + ], autoStart: true, - activate: ( - app: JupyterFrontEnd, - factory: IFileBrowserFactory, - translator: ITranslator - ): void => { - addJupyterLabThemeChangeListener(); - const { commands } = app; - const { tracker } = factory; - const trans = translator.load('jupyter_drives'); - const selectedDrivesModelMap = new Map(); - - let selectedDrives: IDrive[] = [ - { - name: 'CoconutDrive', - url: '/coconut/url' - } - ]; - - const availableDrives: IDrive[] = [ - { - name: 'CoconutDrive', - url: '/coconut/url' - }, - { - name: 'PearDrive', - url: '/pear/url' - }, - { - name: 'StrawberryDrive', - url: '/strawberrydrive/url' - }, - { - name: 'BlueberryDrive', - url: '/blueberrydrive/url' - }, - { - name: '', - url: '/mydrive/url' - }, - { - name: 'RaspberryDrive', - url: '/raspberrydrive/url' - }, - - { - name: 'PineAppleDrive', - url: '' - }, - - { name: 'PomeloDrive', url: '/https://pomelodrive/url' }, - { - name: 'OrangeDrive', - url: '' - }, - { - name: 'TomatoDrive', - url: '' - }, - { - name: '', - url: 'superDrive/url' - }, - { - name: 'AvocadoDrive', - url: '' - } - ]; - let model = selectedDrivesModelMap.get(selectedDrives); + activate: activateAddDrivesPlugin +}; - //const model = new DriveListModel(availableDrives, selectedDrives); +export async function activateAddDrivesPlugin( + app: JupyterFrontEnd, + manager: IDocumentManager, + toolbarRegistry: IToolbarWidgetRegistry, + translator: ITranslator, + restorer: ILayoutRestorer | null, + settingRegistry: ISettingRegistry +) { + console.log('AddDrives plugin is activated!'); + const { commands } = app; + const cocoDrive = new Drive(app.docRegistry); + cocoDrive.name = 'coconutDrive'; + cocoDrive.baseUrl = '/coconut/url'; + cocoDrive.region = ''; + cocoDrive.status = 'active'; + cocoDrive.provider = ''; + const peachDrive = new Drive(app.docRegistry); + peachDrive.baseUrl = '/peach/url'; + peachDrive.name = 'peachDrive'; + const mangoDrive = new Drive(app.docRegistry); + mangoDrive.baseUrl = '/mango/url'; + mangoDrive.name = 'mangoDrive'; + const kiwiDrive = new Drive(app.docRegistry); + kiwiDrive.baseUrl = '/kiwi/url'; + kiwiDrive.name = 'kiwiDrive'; + const pearDrive = new Drive(app.docRegistry); + pearDrive.baseUrl = '/pear/url'; + pearDrive.name = 'pearDrive'; + const customDrive = new Drive(app.docRegistry); + customDrive.baseUrl = '/customDrive/url'; + const tomatoDrive = new Drive(app.docRegistry); + tomatoDrive.baseUrl = '/tomato/url'; + tomatoDrive.name = 'tomatoDrive'; + const avocadoDrive = new Drive(app.docRegistry); + avocadoDrive.baseUrl = '/avocado/url'; + avocadoDrive.name = 'avocadoDrive'; - commands.addCommand(CommandIDs.openDrivesDialog, { - execute: args => { - const widget = tracker.currentWidget; + const selectedList1: Drive[] = []; + const availableList1: Drive[] = [ + avocadoDrive, + cocoDrive, + customDrive, + kiwiDrive, + mangoDrive, + peachDrive, + pearDrive, + tomatoDrive + ]; - if (!model) { - model = new DriveListModel(availableDrives, selectedDrives); - selectedDrivesModelMap.set(selectedDrives, model); - } else { - selectedDrives = model.selectedDrives; - selectedDrivesModelMap.set(selectedDrives, model); - } - if (widget) { - if (model) { - showDialog({ - body: new DriveListView(model), - buttons: [Dialog.cancelButton()] - }); - } - } - }, + function createFilterFileBrowserModel( + manager: IDocumentManager, + drive?: Drive + ): FilterFileBrowserModel { + const driveModel = new FilterFileBrowserModel({ + manager: manager, + driveName: drive?.name + }); + + return driveModel; + } + function buildInitialBrowserModelList(selectedDrives: Drive[]) { + const browserModelList: FilterFileBrowserModel[] = []; + const localDriveModel = createFilterFileBrowserModel(manager); + browserModelList.push(localDriveModel); + return browserModelList; + } + const browserModelList = buildInitialBrowserModelList(selectedList1); + const trans = translator.load('jupyter_drives'); + const panel = new MultiDrivesFileBrowser({ + modelList: browserModelList, + id: '', + manager + }); + panel.title.icon = DriveIcon; + panel.title.iconClass = 'jp-SideBar-tabIcon'; + panel.title.caption = 'Browse Drives'; + panel.id = 'panel-file-browser'; + if (restorer) { + restorer.add(panel, 'drive-browser'); + } + app.shell.add(panel, 'left', { rank: 102 }); - icon: DriveIcon.bindprops({ stylesheet: 'menuItem' }), - caption: trans.__('Add drives to filebrowser.'), - label: trans.__('Add Drives To Filebrowser') + setToolbar( + panel, + createToolbarFactory( + toolbarRegistry, + settingRegistry, + FILE_BROWSER_FACTORY, + FILE_BROWSER_PLUGIN_ID, + translator + ) + ); + function addToBrowserModelList( + browserModelList: FilterFileBrowserModel[], + addedDrive: Drive + ) { + const addedDriveModel = createFilterFileBrowserModel(manager, addedDrive); + browserModelList.push(addedDriveModel); + return browserModelList; + } + function addDriveContentsToPanel( + browserModelList: FilterFileBrowserModel[], + addedDrive: Drive, + panel: MultiDrivesFileBrowser + ) { + const addedDriveModel = createFilterFileBrowserModel(manager, addedDrive); + browserModelList = addToBrowserModelList(browserModelList, addedDrive); + manager.services.contents.addDrive(addedDrive); + const AddedDriveBrowser = new DriveBrowser({ + model: addedDriveModel, + breadCrumbs: new BreadCrumbs({ model: addedDriveModel }), + driveName: addedDrive.name }); + panel.addWidget(AddedDriveBrowser); } -}; -const plugins: JupyterFrontEndPlugin[] = [plugin, openDriveDialogPlugin]; + + /* Dialog to select the drive */ + addJupyterLabThemeChangeListener(); + const selectedDrivesModelMap = new Map(); + let selectedDrives: Drive[] = selectedList1; + const availableDrives: Drive[] = availableList1; + let driveListModel = selectedDrivesModelMap.get(selectedDrives); + + commands.addCommand(CommandIDs.openDrivesDialog, { + execute: async args => { + if (!driveListModel) { + driveListModel = new DriveListModel(availableDrives, selectedDrives); + selectedDrivesModelMap.set(selectedDrives, driveListModel); + } else { + selectedDrives = driveListModel.selectedDrives; + selectedDrivesModelMap.set(selectedDrives, driveListModel); + } + async function onDriveAdded(selectedDrives: Drive[]) { + if (driveListModel) { + const response = driveListModel.sendConnectionRequest(selectedDrives); + if ((await response) === true) { + addDriveContentsToPanel( + browserModelList, + selectedDrives[selectedDrives.length - 1], + panel + ); + } else { + console.warn('Connection with the drive was not possible'); + } + } + } + + if (driveListModel) { + showDialog({ + body: new DriveListView(driveListModel, app.docRegistry), + buttons: [Dialog.cancelButton()] + }); + } + + driveListModel.stateChanged.connect(async () => { + if (driveListModel) { + onDriveAdded(driveListModel.selectedDrives); + } + }); + }, + + icon: DriveIcon.bindprops({ stylesheet: 'menuItem' }), + caption: trans.__('Add drives to filebrowser.'), + label: trans.__('Add Drives To Filebrowser') + }); +} + +const plugins: JupyterFrontEndPlugin[] = [/*plugin,*/ AddDrivesPlugin]; export default plugins; diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..d3a6a5e --- /dev/null +++ b/src/model.ts @@ -0,0 +1,832 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Dialog, showDialog } from '@jupyterlab/apputils'; +import { IChangedArgs, PageConfig, PathExt } from '@jupyterlab/coreutils'; +import { IDocumentManager, shouldOverwrite } from '@jupyterlab/docmanager'; +import { Contents, KernelSpec, Session } from '@jupyterlab/services'; +import { IStateDB } from '@jupyterlab/statedb'; +import { + ITranslator, + nullTranslator, + TranslationBundle +} from '@jupyterlab/translation'; +import { IScore } from '@jupyterlab/ui-components'; +import { ArrayExt, filter } from '@lumino/algorithm'; +import { PromiseDelegate, ReadonlyJSONObject } from '@lumino/coreutils'; +import { IDisposable } from '@lumino/disposable'; +import { Poll } from '@lumino/polling'; +import { ISignal, Signal } from '@lumino/signaling'; + +/** + * The default duration of the auto-refresh in ms + */ +const DEFAULT_REFRESH_INTERVAL = 10000; + +/** + * The maximum upload size (in bytes) for notebook version < 5.1.0 + */ +export const LARGE_FILE_SIZE = 15 * 1024 * 1024; + +/** + * The size (in bytes) of the biggest chunk we should upload at once. + */ +export const CHUNK_SIZE = 1024 * 1024; + +/** + * An upload progress event for a file at `path`. + */ +export interface IUploadModel { + path: string; + /** + * % uploaded [0, 1) + */ + progress: number; +} + +/** + * An implementation of a file browser model. + * + * #### Notes + * All paths parameters without a leading `'/'` are interpreted as relative to + * the current directory. Supports `'../'` syntax. + */ +export class FileBrowserModel implements IDisposable { + /** + * Construct a new file browser model. + */ + constructor(options: FileBrowserModel.IOptions) { + this.manager = options.manager; + this.translator = options.translator || nullTranslator; + this._trans = this.translator.load('jupyterlab'); + this._driveName = options.driveName || ''; + this._model = { + path: this.rootPath, + name: PathExt.basename(this.rootPath), + type: 'directory', + content: undefined, + writable: false, + created: 'unknown', + last_modified: 'unknown', + mimetype: 'text/plain', + format: 'text' + }; + this._state = options.state || null; + const refreshInterval = options.refreshInterval || DEFAULT_REFRESH_INTERVAL; + + const { services } = options.manager; + services.contents.fileChanged.connect(this.onFileChanged, this); + services.sessions.runningChanged.connect(this.onRunningChanged, this); + + this._unloadEventListener = (e: Event) => { + if (this._uploads.length > 0) { + const confirmationMessage = this._trans.__('Files still uploading'); + + (e as any).returnValue = confirmationMessage; + return confirmationMessage; + } + }; + window.addEventListener('beforeunload', this._unloadEventListener); + this._poll = new Poll({ + auto: options.auto ?? true, + name: '@jupyterlab/filebrowser:Model', + factory: () => this.cd('.'), + frequency: { + interval: refreshInterval, + backoff: true, + max: 300 * 1000 + }, + standby: options.refreshStandby || 'when-hidden' + }); + } + + /** + * The document manager instance used by the file browser model. + */ + readonly manager: IDocumentManager; + + /** + * A signal emitted when the file browser model loses connection. + */ + get connectionFailure(): ISignal { + return this._connectionFailure; + } + + /** + * The drive name that gets prepended to the path. + */ + get driveName(): string { + return this._driveName; + } + + /** + * A promise that resolves when the model is first restored. + */ + get restored(): Promise { + return this._restored.promise; + } + + /** + * Get the file path changed signal. + */ + get fileChanged(): ISignal { + return this._fileChanged; + } + + /** + * Get the current path. + */ + get path(): string { + return this._model ? this._model.path : ''; + } + + /** + * Get the root path + */ + get rootPath(): string { + return this._driveName ? this._driveName + ':' : ''; + } + + /** + * A signal emitted when the path changes. + */ + get pathChanged(): ISignal> { + return this._pathChanged; + } + + /** + * A signal emitted when the directory listing is refreshed. + */ + get refreshed(): ISignal { + return this._refreshed; + } + + /** + * Get the kernel spec models. + */ + get specs(): KernelSpec.ISpecModels | null { + return this.manager.services.kernelspecs.specs; + } + + /** + * Get whether the model is disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * A signal emitted when an upload progresses. + */ + get uploadChanged(): ISignal> { + return this._uploadChanged; + } + + /** + * Create an iterator over the status of all in progress uploads. + */ + uploads(): IterableIterator { + return this._uploads[Symbol.iterator](); + } + + /** + * Dispose of the resources held by the model. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + window.removeEventListener('beforeunload', this._unloadEventListener); + this._isDisposed = true; + this._poll.dispose(); + this._sessions.length = 0; + this._items.length = 0; + Signal.clearData(this); + } + + /** + * Create an iterator over the model's items. + * + * @returns A new iterator over the model's items. + */ + items(): IterableIterator { + return this._items[Symbol.iterator](); + } + + /** + * Create an iterator over the active sessions in the directory. + * + * @returns A new iterator over the model's active sessions. + */ + sessions(): IterableIterator { + return this._sessions[Symbol.iterator](); + } + + /** + * Force a refresh of the directory contents. + */ + async refresh(): Promise { + await this._poll.refresh(); + await this._poll.tick; + this._refreshed.emit(void 0); + } + + /** + * Change directory. + * + * @param path - The path to the file or directory. + * + * @returns A promise with the contents of the directory. + */ + async cd(newValue = '.'): Promise { + if (newValue !== '.') { + newValue = this.manager.services.contents.resolvePath( + this._model.path, + newValue + ); + } else { + newValue = this._pendingPath || this._model.path; + } + if (this._pending) { + // Collapse requests to the same directory. + if (newValue === this._pendingPath) { + return this._pending; + } + // Otherwise wait for the pending request to complete before continuing. + await this._pending; + } + const oldValue = this.path; + const options: Contents.IFetchOptions = { content: true }; + this._pendingPath = newValue; + if (oldValue !== newValue) { + this._sessions.length = 0; + } + const services = this.manager.services; + this._pending = services.contents + .get(newValue, options) + .then(contents => { + if (this.isDisposed) { + return; + } + this.handleContents(contents); + this._pendingPath = null; + this._pending = null; + if (oldValue !== newValue) { + // If there is a state database and a unique key, save the new path. + // We don't need to wait on the save to continue. + if (this._state && this._key) { + void this._state.save(this._key, { path: newValue }); + } + + this._pathChanged.emit({ + name: 'path', + oldValue, + newValue + }); + } + this.onRunningChanged(services.sessions, services.sessions.running()); + this._refreshed.emit(void 0); + }) + .catch(error => { + this._pendingPath = null; + this._pending = null; + if ( + error.response && + error.response.status === 404 && + newValue !== '/' + ) { + error.message = this._trans.__( + 'Directory not found: "%1"', + this._model.path + ); + console.error(error); + this._connectionFailure.emit(error); + return this.cd('/'); + } else { + this._connectionFailure.emit(error); + } + }); + return this._pending; + } + + /** + * Download a file. + * + * @param path - The path of the file to be downloaded. + * + * @returns A promise which resolves when the file has begun + * downloading. + */ + async download(path: string): Promise { + const url = await this.manager.services.contents.getDownloadUrl(path); + const element = document.createElement('a'); + element.href = url; + element.download = ''; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + return void 0; + } + + /** + * Restore the state of the file browser. + * + * @param id - The unique ID that is used to construct a state database key. + * + * @param populate - If `false`, the restoration ID will be set but the file + * browser state will not be fetched from the state database. + * + * @returns A promise when restoration is complete. + * + * #### Notes + * This function will only restore the model *once*. If it is called multiple + * times, all subsequent invocations are no-ops. + */ + async restore(id: string, populate = true): Promise { + const { manager } = this; + const key = `file-browser-${id}:cwd`; + const state = this._state; + const restored = !!this._key; + + if (restored) { + return; + } + + // Set the file browser key for state database fetch/save. + this._key = key; + + if (!populate || !state) { + this._restored.resolve(undefined); + return; + } + + await manager.services.ready; + + try { + const value = await state.fetch(key); + + if (!value) { + this._restored.resolve(undefined); + return; + } + + const path = (value as ReadonlyJSONObject)['path'] as string; + // need to return to root path if preferred dir is set + if (path) { + await this.cd('/'); + } + const localPath = manager.services.contents.localPath(path); + + await manager.services.contents.get(path); + await this.cd(localPath); + } catch (error) { + await state.remove(key); + } + + this._restored.resolve(undefined); + } + + /** + * Upload a `File` object. + * + * @param file - The `File` object to upload. + * + * @returns A promise containing the new file contents model. + * + * #### Notes + * On Notebook version < 5.1.0, this will fail to upload files that are too + * big to be sent in one request to the server. On newer versions, or on + * Jupyter Server, it will ask for confirmation then upload the file in 1 MB + * chunks. + */ + async upload(file: File): Promise { + // We do not support Jupyter Notebook version less than 4, and Jupyter + // Server advertises itself as version 1 and supports chunked + // uploading. We assume any version less than 4.0.0 to be Jupyter Server + // instead of Jupyter Notebook. + const serverVersion = PageConfig.getNotebookVersion(); + const supportsChunked = + serverVersion < [4, 0, 0] /* Jupyter Server */ || + serverVersion >= [5, 1, 0]; /* Jupyter Notebook >= 5.1.0 */ + const largeFile = file.size > LARGE_FILE_SIZE; + + if (largeFile && !supportsChunked) { + const msg = this._trans.__( + 'Cannot upload file (>%1 MB). %2', + LARGE_FILE_SIZE / (1024 * 1024), + file.name + ); + console.warn(msg); + throw msg; + } + + const err = 'File not uploaded'; + if (largeFile && !(await this._shouldUploadLarge(file))) { + throw 'Cancelled large file upload'; + } + await this._uploadCheckDisposed(); + await this.refresh(); + await this._uploadCheckDisposed(); + if ( + this._items.find(i => i.name === file.name) && + !(await shouldOverwrite(file.name)) + ) { + throw err; + } + await this._uploadCheckDisposed(); + const chunkedUpload = supportsChunked && file.size > CHUNK_SIZE; + return await this._upload(file, chunkedUpload); + } + + private async _shouldUploadLarge(file: File): Promise { + const { button } = await showDialog({ + title: this._trans.__('Large file size warning'), + body: this._trans.__( + 'The file size is %1 MB. Do you still want to upload it?', + Math.round(file.size / (1024 * 1024)) + ), + buttons: [ + Dialog.cancelButton({ label: this._trans.__('Cancel') }), + Dialog.warnButton({ label: this._trans.__('Upload') }) + ] + }); + return button.accept; + } + + /** + * Perform the actual upload. + */ + private async _upload( + file: File, + chunked: boolean + ): Promise { + // Gather the file model parameters. + let path = this._model.path; + path = path ? path + '/' + file.name : file.name; + const name = file.name; + const type: Contents.ContentType = 'file'; + const format: Contents.FileFormat = 'base64'; + + const uploadInner = async ( + blob: Blob, + chunk?: number + ): Promise => { + await this._uploadCheckDisposed(); + const reader = new FileReader(); + reader.readAsDataURL(blob); + await new Promise((resolve, reject) => { + reader.onload = resolve; + reader.onerror = event => + reject(`Failed to upload "${file.name}":` + event); + }); + await this._uploadCheckDisposed(); + + // remove header https://stackoverflow.com/a/24289420/907060 + const content = (reader.result as string).split(',')[1]; + + const model: Partial = { + type, + format, + name, + chunk, + content + }; + return await this.manager.services.contents.save(path, model); + }; + + if (!chunked) { + try { + return await uploadInner(file); + } catch (err) { + ArrayExt.removeFirstWhere(this._uploads, uploadIndex => { + return file.name === uploadIndex.path; + }); + throw err; + } + } + + let finalModel: Contents.IModel | undefined; + + let upload = { path, progress: 0 }; + this._uploadChanged.emit({ + name: 'start', + newValue: upload, + oldValue: null + }); + + for (let start = 0; !finalModel; start += CHUNK_SIZE) { + const end = start + CHUNK_SIZE; + const lastChunk = end >= file.size; + const chunk = lastChunk ? -1 : end / CHUNK_SIZE; + + const newUpload = { path, progress: start / file.size }; + this._uploads.splice(this._uploads.indexOf(upload)); + this._uploads.push(newUpload); + this._uploadChanged.emit({ + name: 'update', + newValue: newUpload, + oldValue: upload + }); + upload = newUpload; + + let currentModel: Contents.IModel; + try { + currentModel = await uploadInner(file.slice(start, end), chunk); + } catch (err) { + ArrayExt.removeFirstWhere(this._uploads, uploadIndex => { + return file.name === uploadIndex.path; + }); + + this._uploadChanged.emit({ + name: 'failure', + newValue: upload, + oldValue: null + }); + + throw err; + } + + if (lastChunk) { + finalModel = currentModel; + } + } + + this._uploads.splice(this._uploads.indexOf(upload)); + this._uploadChanged.emit({ + name: 'finish', + newValue: null, + oldValue: upload + }); + + return finalModel; + } + + private _uploadCheckDisposed(): Promise { + if (this.isDisposed) { + return Promise.reject('Filemanager disposed. File upload canceled'); + } + return Promise.resolve(); + } + + /** + * Handle an updated contents model. + */ + protected handleContents(contents: Contents.IModel): void { + // Update our internal data. + this._model = { + name: contents.name, + path: contents.path, + type: contents.type, + content: undefined, + writable: contents.writable, + created: contents.created, + last_modified: contents.last_modified, + size: contents.size, + mimetype: contents.mimetype, + format: contents.format + }; + this._items = contents.content; + this._paths.clear(); + contents.content.forEach((model: Contents.IModel) => { + this._paths.add(model.path); + }); + } + + /** + * Handle a change to the running sessions. + */ + protected onRunningChanged( + sender: Session.IManager, + models: Iterable + ): void { + this._populateSessions(models); + this._refreshed.emit(void 0); + } + + /** + * Handle a change on the contents manager. + */ + protected onFileChanged( + sender: Contents.IManager, + change: Contents.IChangedArgs + ): void { + const path = this._model.path; + const { sessions } = this.manager.services; + const { oldValue, newValue } = change; + const value = + oldValue && oldValue.path && PathExt.dirname(oldValue.path) === path + ? oldValue + : newValue && newValue.path && PathExt.dirname(newValue.path) === path + ? newValue + : undefined; + + // If either the old value or the new value is in the current path, update. + if (value) { + void this._poll.refresh(); + this._populateSessions(sessions.running()); + this._fileChanged.emit(change); + return; + } + } + + /** + * Populate the model's sessions collection. + */ + private _populateSessions(models: Iterable): void { + this._sessions.length = 0; + for (const model of models) { + if (this._paths.has(model.path)) { + this._sessions.push(model); + } + } + } + + protected translator: ITranslator; + private _trans: TranslationBundle; + private _connectionFailure = new Signal(this); + private _fileChanged = new Signal(this); + private _items: Contents.IModel[] = []; + private _key: string = ''; + private _model: Contents.IModel; + private _pathChanged = new Signal>(this); + private _paths = new Set(); + private _pending: Promise | null = null; + private _pendingPath: string | null = null; + private _refreshed = new Signal(this); + private _sessions: Session.IModel[] = []; + private _state: IStateDB | null = null; + private _driveName: string; + private _isDisposed = false; + private _restored = new PromiseDelegate(); + private _uploads: IUploadModel[] = []; + private _uploadChanged = new Signal>( + this + ); + private _unloadEventListener: (e: Event) => string | undefined; + private _poll: Poll; +} + +/** + * The namespace for the `FileBrowserModel` class statics. + */ +export namespace FileBrowserModel { + /** + * An options object for initializing a file browser. + */ + export interface IOptions { + /** + * Whether a file browser automatically loads its initial path. + * The default is `true`. + */ + auto?: boolean; + + /** + * An optional `Contents.IDrive` name for the model. + * If given, the model will prepend `driveName:` to + * all paths used in file operations. + */ + driveName?: string; + + /** + * A document manager instance. + */ + manager: IDocumentManager; + + /** + * The time interval for browser refreshing, in ms. + */ + refreshInterval?: number; + + /** + * When the model stops polling the API. Defaults to `when-hidden`. + */ + refreshStandby?: Poll.Standby | (() => boolean | Poll.Standby); + + /** + * An optional state database. If provided, the model will restore which + * folder was last opened when it is restored. + */ + state?: IStateDB; + + /** + * The application language translator. + */ + translator?: ITranslator; + } +} + +/** + * File browser model where hidden files inclusion can be toggled on/off. + */ +export class TogglableHiddenFileBrowserModel extends FileBrowserModel { + constructor(options: TogglableHiddenFileBrowserModel.IOptions) { + super(options); + this._includeHiddenFiles = options.includeHiddenFiles || false; + } + + /** + * Create an iterator over the model's items filtering hidden files out if necessary. + * + * @returns A new iterator over the model's items. + */ + items(): IterableIterator { + return this._includeHiddenFiles + ? super.items() + : filter(super.items(), value => !value.name.startsWith('.')); + } + + /** + * Set the inclusion of hidden files. Triggers a model refresh. + */ + showHiddenFiles(value: boolean): void { + this._includeHiddenFiles = value; + void this.refresh(); + } + + private _includeHiddenFiles: boolean; +} + +/** + * Namespace for the togglable hidden file browser model + */ +export namespace TogglableHiddenFileBrowserModel { + /** + * Constructor options + */ + export interface IOptions extends FileBrowserModel.IOptions { + /** + * Whether hidden files should be included in the items. + */ + includeHiddenFiles?: boolean; + } +} + +/** + * File browser model with optional filter on element. + */ +export class FilterFileBrowserModel extends TogglableHiddenFileBrowserModel { + constructor(options: FilterFileBrowserModel.IOptions) { + super(options); + this._filter = + options.filter ?? + (model => { + return {}; + }); + this._filterDirectories = options.filterDirectories ?? true; + } + + /** + * Whether to filter directories. + */ + get filterDirectories(): boolean { + return this._filterDirectories; + } + set filterDirectories(value: boolean) { + this._filterDirectories = value; + } + + /** + * Create an iterator over the filtered model's items. + * + * @returns A new iterator over the model's items. + */ + items(): IterableIterator { + return filter(super.items(), value => { + if (!this._filterDirectories && value.type === 'directory') { + return true; + } else { + const filtered = this._filter(value); + value.indices = filtered?.indices; + return !!filtered; + } + }); + } + + setFilter(filter: (value: Contents.IModel) => Partial | null): void { + this._filter = filter; + void this.refresh(); + } + + private _filter: (value: Contents.IModel) => Partial | null; + private _filterDirectories: boolean; +} + +/** + * Namespace for the filtered file browser model + */ +export namespace FilterFileBrowserModel { + /** + * Constructor options + */ + export interface IOptions extends TogglableHiddenFileBrowserModel.IOptions { + /** + * Filter function on file browser item model + */ + filter?: (value: Contents.IModel) => Partial | null; + + /** + * Filter directories + */ + filterDirectories?: boolean; + } +} diff --git a/src/multidrivesbrowser.ts b/src/multidrivesbrowser.ts new file mode 100644 index 0000000..d8175a8 --- /dev/null +++ b/src/multidrivesbrowser.ts @@ -0,0 +1,244 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { showErrorMessage } from '@jupyterlab/apputils'; +import { Contents } from '@jupyterlab/services'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { SidePanel } from '@jupyterlab/ui-components'; +import { + BreadCrumbs, + FilterFileBrowserModel, + DirListing +} from '@jupyterlab/filebrowser'; +import { IDocumentManager } from '@jupyterlab/docmanager'; +import { AccordionPanel } from '@lumino/widgets'; +import { BreadCrumbsLayout } from './crumbslayout'; +import { DriveBrowser } from './drivebrowser'; + +/* + * The class name added to file browsers. + */ +const FILE_BROWSER_CLASS = 'jp-FileBrowser'; + +/** + * The class name added to file browser panel (gather filter, breadcrumbs and listing). + */ +const FILE_BROWSER_PANEL_CLASS = 'jp-MultiDrivesFileBrowser-Panel'; + +/** + * The class name added to the filebrowser toolbar node. + */ +const TOOLBAR_CLASS = 'jp-FileBrowser-toolbar'; + +/** + * The class name added to the filebrowser listing node. + */ +const LISTING_CLASS = 'jp-FileBrowser-listing'; + +export class MultiDrivesFileBrowser extends SidePanel { + /** + * Construct a new file browser with multiple drivelistings. + * + * @param options - The file browser options. + */ + constructor(options: MultiDrivesFileBrowser.IOptions) { + super({ + content: new AccordionPanel({ + layout: new BreadCrumbsLayout({ + renderer: BreadCrumbsLayout.defaultRenderer + }) + }) + }); + + this.addClass(FILE_BROWSER_CLASS); + + this.toolbar.addClass(TOOLBAR_CLASS); + this.id = options.id; + + const translator = (this.translator = options.translator ?? nullTranslator); + const modelList = (this.modelList = options.modelList); + this.manager = options.manager; + + this.addClass(FILE_BROWSER_PANEL_CLASS); + this.title.label = this._trans.__(''); + + this.toolbar.node.setAttribute('role', 'navigation'); + this.toolbar.node.setAttribute( + 'aria-label', + this._trans.__('file browser') + ); + + const renderer = options.renderer; + + modelList.forEach(model => { + let driveName = model.driveName; + if (model.driveName === '') { + driveName = 'Local Drive'; + } + console.log('driveName:', driveName); + const listing = new DriveBrowser({ + model: model, + translator: translator, + renderer: renderer, + breadCrumbs: new BreadCrumbs({ + model: model, + translator: translator + }), + driveName: driveName + }); + + listing.addClass(LISTING_CLASS); + this.addWidget(listing); + + if (options.restore !== false) { + void model.restore(this.id); + } + }); + } + + /** + * Create the underlying DirListing instance. + * + * @param options - The DirListing constructor options. + * + * @returns The created DirListing instance. + */ + protected createDriveBrowser(options: DriveBrowser.IOptions): DriveBrowser { + return new DriveBrowser(options); + } + + /** + * Rename the first currently selected item. + * + * @returns A promise that resolves with the new name of the item. + */ + rename(listing: DriveBrowser): Promise { + return listing.rename(); + } + + private async _createNew( + options: Contents.ICreateOptions, + listing: DriveBrowser + ): Promise { + try { + const model = await this.manager.newUntitled(options); + + await listing.selectItemByName(model.name, true); + await listing.rename(); + return model; + } catch (error: any) { + void showErrorMessage(this._trans.__('Error'), error); + throw error; + } + } + + /** + * Create a new directory + */ + async createNewDirectory( + model: FilterFileBrowserModel, + listing: DriveBrowser + ): Promise { + if (this._directoryPending) { + return this._directoryPending; + } + this._directoryPending = this._createNew( + { + path: model.path, + type: 'directory' + }, + listing + ); + try { + return await this._directoryPending; + } finally { + this._directoryPending = null; + } + } + + /** + * Create a new file + */ + async createNewFile( + options: MultiDrivesFileBrowser.IFileOptions, + model: FilterFileBrowserModel, + listing: DriveBrowser + ): Promise { + if (this._filePending) { + return this._filePending; + } + this._filePending = this._createNew( + { + path: model.path, + type: 'file', + ext: options.ext + }, + listing + ); + try { + return await this._filePending; + } finally { + this._filePending = null; + } + } + + protected translator: ITranslator; + private manager: IDocumentManager; + private _directoryPending: Promise | null = null; + private _filePending: Promise | null = null; + readonly modelList: FilterFileBrowserModel[]; +} + +export namespace MultiDrivesFileBrowser { + /** + * An options object for initializing a file browser widget. + */ + export interface IOptions { + /** + * The widget/DOM id of the file browser. + */ + id: string; + + /** + * A file browser model instance. + */ + modelList: FilterFileBrowserModel[]; + + /** + * A file browser document document manager + */ + manager: IDocumentManager; + + /** + * An optional renderer for the directory listing area. + * + * The default is a shared instance of `DirListing.Renderer`. + */ + renderer?: DirListing.IRenderer; + + /** + * Whether a file browser automatically restores state when instantiated. + * The default is `true`. + * + * #### Notes + * The file browser model will need to be restored manually for the file + * browser to be able to save its state. + */ + restore?: boolean; + + /** + * The application language translator. + */ + translator?: ITranslator; + } + + /** + * An options object for creating a file. + */ + export interface IFileOptions { + /** + * The file extension. + */ + ext: string; + } +} diff --git a/style/base.css b/style/base.css index e0d3b8e..9227178 100644 --- a/style/base.css +++ b/style/base.css @@ -57,10 +57,39 @@ li { } .data-grid-cell { - text-align: justify; + text-align: left; height: 2em; - min-width: 200px; + /*min-width: 200px;*/ border-right: 2px; border-left: 2px; background-color: var(--jp-layout-color2); } + +.jp-Dialog-body { + width: 800px; + height: 800px; +} +.jp-DirListing-header { + display: none; +} + +/*.lm-SplitPanel-handle { + display: none; +}*/ + +.lm-AccordionPanel .jp-AccordionPanel-title { + box-sizing: border-box; + line-height: 24px; + max-height: 24px; + margin: 0; + display: flex; + align-items: center; + color: var(--jp-ui-font-color1); + border-bottom: var(--jp-border-width) solid var(--jp-toolbar-border-color); + box-shadow: var(--jp-toolbar-box-shadow); + font-size: var(--jp-ui-font-size0); +} + +.jp-FileBrowser .lm-AccordionPanel > h3:first-child { + display: flex; +} diff --git a/style/drive.svg b/style/drive.svg index 3fbc51e..f064d5b 100644 --- a/style/drive.svg +++ b/style/drive.svg @@ -1,4 +1,3 @@ - + >