From 578ac6cae5fe9c7915ff698eb183720a13270e34 Mon Sep 17 00:00:00 2001 From: Kevin Cunnane Date: Thu, 20 Jun 2019 11:00:24 -0700 Subject: [PATCH] Add notebook open protocol handler (#6093) Adds a protocol handler for notebook open, which can be used from browsers Uses extension-based handler support so all URIs must start with `azuredatastudio://microsoft.notebook` Adds 2 actions: - `/new` opens a new empty notebook - `/open` opens a HTTP/S file as an untitled notebook or text document Sample URL: ``` azuredatastudio://microsoft.notebook/open?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkevcunnane%2Fmsbuild_ads_demo%2Fmaster%2F0_YoAzdata.ipynb ``` --- extensions/notebook/src/common/utils.ts | 7 + extensions/notebook/src/extension.ts | 9 +- .../src/protocol/notebookUriHandler.ts | 131 ++++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 extensions/notebook/src/protocol/notebookUriHandler.ts diff --git a/extensions/notebook/src/common/utils.ts b/extensions/notebook/src/common/utils.ts index 12baba0755..7a2313f321 100644 --- a/extensions/notebook/src/common/utils.ts +++ b/extensions/notebook/src/common/utils.ts @@ -7,6 +7,7 @@ import * as childProcess from 'child_process'; import * as fs from 'fs-extra'; import * as nls from 'vscode-nls'; import * as vscode from 'vscode'; +import * as azdata from 'azdata'; const localize = nls.loadMessageBundle(); @@ -135,3 +136,9 @@ function outputDataChunk(data: string | Buffer, outputChannel: vscode.OutputChan outputChannel.appendLine(header + line); }); } + +export function isEditorTitleFree(title: string): boolean { + let hasTextDoc = vscode.workspace.textDocuments.findIndex(doc => doc.isUntitled && doc.fileName === title) > -1; + let hasNotebookDoc = azdata.nb.notebookDocuments.findIndex(doc => doc.isUntitled && doc.fileName === title) > -1; + return !hasTextDoc && !hasNotebookDoc; +} \ No newline at end of file diff --git a/extensions/notebook/src/extension.ts b/extensions/notebook/src/extension.ts index d423dee50b..cd4ae05b90 100644 --- a/extensions/notebook/src/extension.ts +++ b/extensions/notebook/src/extension.ts @@ -13,7 +13,8 @@ import { AppContext } from './common/appContext'; import { ApiWrapper } from './common/apiWrapper'; import { IExtensionApi } from './types'; import { CellType } from './contracts/content'; -import { getErrorMessage } from './common/utils'; +import { getErrorMessage, isEditorTitleFree } from './common/utils'; +import { NotebookUriHandler } from './protocol/notebookUriHandler'; const localize = nls.loadMessageBundle(); @@ -74,6 +75,8 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.analyzeNotebook', (explorerContext: azdata.ObjectExplorerContext) => { analyzeNotebook(explorerContext); })); + extensionContext.subscriptions.push(vscode.window.registerUriHandler(new NotebookUriHandler())); + let appContext = new AppContext(extensionContext, new ApiWrapper()); controller = new JupyterController(appContext); @@ -112,9 +115,7 @@ function findNextUntitledEditorName(): string { // Note: this will go forever if it's coded wrong, or you have infinite Untitled notebooks! while (true) { let title = `Notebook-${nextVal}`; - let hasTextDoc = vscode.workspace.textDocuments.findIndex(doc => doc.isUntitled && doc.fileName === title) > -1; - let hasNotebookDoc = azdata.nb.notebookDocuments.findIndex(doc => doc.isUntitled && doc.fileName === title) > -1; - if (!hasTextDoc && !hasNotebookDoc) { + if (isEditorTitleFree(title)) { return title; } nextVal++; diff --git a/extensions/notebook/src/protocol/notebookUriHandler.ts b/extensions/notebook/src/protocol/notebookUriHandler.ts new file mode 100644 index 0000000000..b7bb34186d --- /dev/null +++ b/extensions/notebook/src/protocol/notebookUriHandler.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as azdata from 'azdata'; + +import * as request from 'request'; +import * as path from 'path'; +import * as querystring from 'querystring'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); +import { IQuestion, QuestionTypes } from '../prompts/question'; +import CodeAdapter from '../prompts/adapter'; +import { getErrorMessage, isEditorTitleFree } from '../common/utils'; + +export class NotebookUriHandler implements vscode.UriHandler { + private prompter = new CodeAdapter(); + + constructor() { + } + + handleUri(uri: vscode.Uri): vscode.ProviderResult { + switch (uri.path) { + case '/new': + vscode.commands.executeCommand('notebook.command.new'); + break; + case '/open': + this.open(uri); + break; + default: + vscode.window.showErrorMessage(localize('notebook.unsupportedAction', "Action {0} is not supported for this handler", uri.path)); + } + } + + private open(uri: vscode.Uri): void { + const data = querystring.parse(uri.query); + + if (!data.url) { + console.warn('Failed to open URI:', uri); + } + + this.openNotebook(data.url); + } + + private async openNotebook(url: string | string[]): Promise { + try { + if (Array.isArray(url)) { + url = url[0]; + } + url = decodeURI(url); + let uri = vscode.Uri.parse(url); + switch (uri.scheme) { + case 'http': + case 'https': + break; + default: + vscode.window.showErrorMessage(localize('unsupportedScheme', "Cannot open link {0} as only HTTP and HTTPS links are supported", url)); + return; + } + + let doOpen = await this.prompter.promptSingle({ + type: QuestionTypes.confirm, + message: localize('notebook.confirmOpen', "Download and open '{0}'?", url), + default: true + }); + if (!doOpen) { + return; + } + + let contents = await this.download(url); + let untitledUri = this.getUntitledUri(path.basename(uri.fsPath)); + if (path.extname(uri.fsPath) === '.ipynb') { + await azdata.nb.showNotebookDocument(untitledUri, { + initialContent: contents, + preserveFocus: true + }); + } else { + let doc = await vscode.workspace.openTextDocument(untitledUri); + let editor = await vscode.window.showTextDocument(doc, vscode.ViewColumn.Active, true); + await editor.edit(builder => { + builder.insert(new vscode.Position(0, 0), contents); + }); + } + } catch (err) { + vscode.window.showErrorMessage(getErrorMessage(err)); + } + } + + private download(url: string): Promise { + return new Promise((resolve, reject) => { + request.get(url, { timeout: 10000 }, (error, response, body) => { + if (error) { + return reject(error); + } + + if (response.statusCode === 404) { + return reject(localize('notebook.fileNotFound', "Could not find the specified file")); + } + + if (response.statusCode !== 200) { + return reject( + localize('notebook.fileDownloadError', + "File open request failed with error: {0} {1}", + response.statusCode, + response.statusMessage)); + } + + resolve(body); + }); + }); + } + + private getUntitledUri(originalTitle: string): vscode.Uri { + let title = originalTitle; + let nextVal = 0; + let ext = path.extname(title); + while (!isEditorTitleFree(title)) { + if (ext) { + // Need it to be `Readme-0.txt` not `Readme.txt-0` + let titleStart = originalTitle.slice(0, originalTitle.length - ext.length); + title = `${titleStart}-${nextVal}${ext}`; + } else { + title = `${originalTitle}-${nextVal}`; + } + nextVal++; + } + return vscode.Uri.parse(`untitled:${title}`); + } +}