diff --git a/jupyter_drives/handlers.py b/jupyter_drives/handlers.py index 9b3a775..6e8dbd2 100644 --- a/jupyter_drives/handlers.py +++ b/jupyter_drives/handlers.py @@ -89,7 +89,10 @@ async def get(self, drive: str = "", path: str = ""): @tornado.web.authenticated async def post(self, drive: str = "", path: str = ""): body = self.get_json_body() - result = await self._manager.new_file(drive, path, **body) + if 'location' in body: + result = await self._manager.new_drive(drive, **body) + else: + result = await self._manager.new_file(drive, path, **body) self.finish(result) @tornado.web.authenticated diff --git a/jupyter_drives/manager.py b/jupyter_drives/manager.py index ccc990d..10c105e 100644 --- a/jupyter_drives/manager.py +++ b/jupyter_drives/manager.py @@ -445,17 +445,22 @@ async def delete_file(self, drive_name, path): try: # eliminate leading and trailing backslashes path = path.strip('/') - is_dir = await self._file_system._isdir(drive_name + '/' + path) - if is_dir == True: - await self._fix_dir(drive_name, path) - await self._file_system._rm(drive_name + '/' + path, recursive = True) + object_name = drive_name # in case we are only deleting the drive itself + if path != '': + # deleting objects within a drive + is_dir = await self._file_system._isdir(drive_name + '/' + path) + if is_dir == True: + await self._fix_dir(drive_name, path) + object_name = drive_name + '/' + path + await self._file_system._rm(object_name, recursive = True) # checking for remaining directories and deleting them - stream = obs.list(self._content_managers[drive_name]["store"], path, chunk_size=100, return_arrow=True) - async for batch in stream: - contents_list = pyarrow.record_batch(batch).to_pylist() - for object in contents_list: - await self._fix_dir(drive_name, object["path"], delete_only = True) + if object_name != drive_name: + stream = obs.list(self._content_managers[drive_name]["store"], path, chunk_size=100, return_arrow=True) + async for batch in stream: + contents_list = pyarrow.record_batch(batch).to_pylist() + for object in contents_list: + await self._fix_dir(drive_name, object["path"], delete_only = True) except Exception as e: raise tornado.web.HTTPError( @@ -563,6 +568,23 @@ async def check_file(self, drive_name, path): return + async def new_drive(self, new_drive_name, location='us-east-1'): + """Create a new drive in the given location. + + Args: + new_drive_name: name of new drive to create + location: (optional) region of bucket + """ + try: + await self._file_system._mkdir(new_drive_name, region_name = location) + except Exception as e: + raise tornado.web.HTTPError( + status_code= httpx.codes.BAD_REQUEST, + reason=f"The following error occured when creating the new drive: {e}", + ) + + return + async def _get_drive_location(self, drive_name): """Helping function for getting drive region. diff --git a/src/contents.ts b/src/contents.ts index d430c36..8a81f2a 100644 --- a/src/contents.ts +++ b/src/contents.ts @@ -19,7 +19,9 @@ import { countObjectNameAppearances, renameObjects, copyObjects, - presignedLink + presignedLink, + createDrive, + getDrivesList } from './requests'; let data: Contents.IModel = { @@ -244,20 +246,29 @@ export class Drive implements Contents.IDrive { } else { // retriving list of contents from root // in our case: list available drives - const drivesList: Contents.IModel[] = []; - for (const drive of this._drivesList) { - drivesList.push({ - name: drive.name, - path: drive.name, - last_modified: '', - created: drive.creationDate, - content: [], - format: 'json', - mimetype: '', - size: undefined, - writable: true, - type: 'directory' - }); + const drivesListInfo: Contents.IModel[] = []; + // fetch list of available drives + try { + this._drivesList = await getDrivesList(); + for (const drive of this._drivesList) { + drivesListInfo.push({ + name: drive.name, + path: drive.name, + last_modified: '', + created: drive.creationDate, + content: [], + format: 'json', + mimetype: '', + size: undefined, + writable: true, + type: 'directory' + }); + } + } catch (error) { + console.log( + 'Failed loading available drives list, with error: ', + error + ); } data = { @@ -265,7 +276,7 @@ export class Drive implements Contents.IDrive { path: this._name, last_modified: '', created: '', - content: drivesList, + content: drivesListInfo, format: 'json', mimetype: '', size: undefined, @@ -390,16 +401,11 @@ export class Drive implements Contents.IDrive { * @returns A promise which resolves when the file is deleted. */ async delete(localPath: string): Promise { - if (localPath !== '') { - const currentDrive = extractCurrentDrive(localPath, this._drivesList); + const currentDrive = extractCurrentDrive(localPath, this._drivesList); - await deleteObjects(currentDrive.name, { - path: formatPath(localPath) - }); - } else { - // create new element at root would mean modifying a drive - console.warn('Operation not supported.'); - } + await deleteObjects(currentDrive.name, { + path: formatPath(localPath) + }); this._fileChanged.emit({ type: 'delete', @@ -630,6 +636,31 @@ export class Drive implements Contents.IDrive { return data; } + /** + * Create a new drive. + * + * @param options: The options used to create the drive. + * + * @returns A promise which resolves with the contents model. + */ + async newDrive( + newDriveName: string, + region: string + ): Promise { + data = await createDrive(newDriveName, { + location: region + }); + + Contents.validateContentsModel(data); + this._fileChanged.emit({ + type: 'new', + oldValue: null, + newValue: data + }); + + return data; + } + /** * Create a checkpoint for a file. * diff --git a/src/plugins/driveBrowserPlugin.ts b/src/plugins/driveBrowserPlugin.ts index eca6cde..f666c4f 100644 --- a/src/plugins/driveBrowserPlugin.ts +++ b/src/plugins/driveBrowserPlugin.ts @@ -14,11 +14,14 @@ import { ITranslator } from '@jupyterlab/translation'; import { createToolbarFactory, IToolbarWidgetRegistry, - setToolbar + setToolbar, + showDialog, + Dialog } from '@jupyterlab/apputils'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { FilenameSearcher, IScore } from '@jupyterlab/ui-components'; import { CommandRegistry } from '@lumino/commands'; +import { Widget } from '@lumino/widgets'; import { driveBrowserIcon } from '../icons'; import { Drive } from '../contents'; @@ -35,6 +38,16 @@ const FILE_BROWSER_FACTORY = 'DriveBrowser'; */ const FILTERBOX_CLASS = 'jp-drive-browser-search-box'; +/** + * The class name added to dialogs. + */ +const FILE_DIALOG_CLASS = 'jp-FileDialog'; + +/** + * The class name added for the new drive label in the creating new drive dialog. + */ +const CREATE_DRIVE_TITLE_CLASS = 'jp-new-drive-title'; + /** * The drives list provider. */ @@ -184,6 +197,9 @@ export const driveFileBrowser: JupyterFrontEndPlugin = { // Listen for your plugin setting changes using Signal setting.changed.connect(loadSetting); + + // Add commands + Private.addCommands(app, drive); }) .catch(reason => { console.error( @@ -246,4 +262,101 @@ namespace Private { }; router.routed.connect(listener); } + + /** + * Create the node for a creating a new drive handler. + */ + const createNewDriveNode = (newDriveName: string): HTMLElement => { + const body = document.createElement('div'); + + const drive = document.createElement('label'); + drive.textContent = 'Name'; + drive.className = CREATE_DRIVE_TITLE_CLASS; + const driveName = document.createElement('input'); + + const region = document.createElement('label'); + region.textContent = 'Region'; + region.className = CREATE_DRIVE_TITLE_CLASS; + const regionName = document.createElement('input'); + regionName.placeholder = 'us-east-1'; + + body.appendChild(drive); + body.appendChild(driveName); + body.appendChild(region); + body.appendChild(regionName); + return body; + }; + + /** + * A widget used to create a new drive. + */ + export class CreateDriveHandler extends Widget { + /** + * Construct a new "create-drive" dialog. + */ + constructor(newDriveName: string) { + super({ node: createNewDriveNode(newDriveName) }); + this.onAfterAttach(); + } + + protected onAfterAttach(): void { + this.addClass(FILE_DIALOG_CLASS); + const drive = this.driveInput.value; + this.driveInput.setSelectionRange(0, drive.length); + const region = this.regionInput.value; + this.regionInput.setSelectionRange(0, region.length); + } + + /** + * Get the input text node for drive name. + */ + get driveInput(): HTMLInputElement { + return this.node.getElementsByTagName('input')[0] as HTMLInputElement; + } + + /** + * Get the input text node for region. + */ + get regionInput(): HTMLInputElement { + return this.node.getElementsByTagName('input')[1] as HTMLInputElement; + } + + /** + * Get the value of the widget. + */ + getValue(): string[] { + return [this.driveInput.value, this.regionInput.value]; + } + } + + export function addCommands(app: JupyterFrontEnd, drive: Drive): void { + app.commands.addCommand(CommandIDs.createNewDrive, { + execute: async () => { + return showDialog({ + title: 'New Drive', + body: new Private.CreateDriveHandler(drive.name), + focusNodeSelector: 'input', + buttons: [ + Dialog.cancelButton(), + Dialog.okButton({ + label: 'Create', + ariaLabel: 'Create New Drive' + }) + ] + }).then(result => { + if (result.value) { + drive.newDrive(result.value[0], result.value[1]); + } + }); + }, + label: 'New Drive', + icon: driveBrowserIcon.bindprops({ stylesheet: 'menuItem' }) + }); + + app.contextMenu.addItem({ + command: CommandIDs.createNewDrive, + selector: '#drive-file-browser.jp-SidePanel .jp-DirListing-content', + rank: 100 + }); + } } diff --git a/src/requests.ts b/src/requests.ts index fba4114..ebfb222 100644 --- a/src/requests.ts +++ b/src/requests.ts @@ -500,6 +500,39 @@ export const countObjectNameAppearances = async ( return counter; }; +/** + * Create a new drive. + * + * @param newDriveName The new drive name. + * @param options.location The region where drive should be located. + * + * @returns A promise which resolves with the contents model. + */ +export async function createDrive( + newDriveName: string, + options: { + location: string; + } +) { + await requestAPI('drives/' + newDriveName + '/', 'POST', { + location: options.location + }); + + data = { + name: newDriveName, + path: newDriveName, + last_modified: '', + created: '', + content: [], + format: 'json', + mimetype: '', + size: 0, + writable: true, + type: 'directory' + }; + return data; +} + namespace Private { /** * Helping function for renaming files inside diff --git a/src/token.ts b/src/token.ts index 1516805..33e0149 100644 --- a/src/token.ts +++ b/src/token.ts @@ -8,6 +8,7 @@ export namespace CommandIDs { export const openDrivesDialog = 'drives:open-drives-dialog'; export const openPath = 'drives:open-path'; export const toggleBrowser = 'drives:toggle-main'; + export const createNewDrive = 'drives:create-new-drive'; export const launcher = 'launcher:create'; }