diff --git a/ui/index.d.ts b/ui/index.d.ts index a13a70e8fa..2dce6fe4dd 100644 --- a/ui/index.d.ts +++ b/ui/index.d.ts @@ -7,7 +7,7 @@ import { Subscription } from 'azure-arm-resource/lib/subscription/models'; import { ServiceClientCredentials } from 'ms-rest'; import { AzureEnvironment } from 'ms-rest-azure'; -import { Uri, TreeDataProvider, Disposable, TreeItem, Event } from 'vscode'; +import { Uri, TreeDataProvider, Disposable, TreeItem, Event, OutputChannel, Memento, TextDocument } from 'vscode'; export declare class AzureTreeDataProvider implements TreeDataProvider, Disposable { public static readonly subscriptionContextValue: string; @@ -107,3 +107,42 @@ export interface IAzureParentTreeItem extends IAzureTreeItem, IChildProvider { } export declare class UserCancelledError extends Error { } + +export declare abstract class BaseEditor implements Disposable { + /** + * Implement this interface if you need to download and upload remote files + * @param showSavePromptKey Key used globally by VS Code to determine whether or not to show the savePrompt + * @param outputChannel OutputChannel where output will be displayed when editor performs actions + */ + constructor(showSavePromptKey: string, outputChannel?: OutputChannel | undefined); + + /** + * Implement this to retrieve data from your remote server, returns the file as a string + */ + abstract getData(context: ContextT): Promise; + + /** + * Implement this to allow for remote updating + */ + abstract updateData(context: ContextT, data: string): Promise; + + /** + * Implement this to return the file name from the remote + */ + abstract getFilename(context: ContextT): Promise; + + /** + * Implement this to return the size in MB. + */ + abstract getSize(context: ContextT): Promise; + + /** + * Implement this to edit what is displayed to the user when uploading the file to the remote + */ + abstract getSaveConfirmationText(context: ContextT): Promise; + + onDidSaveTextDocument(globalState: Memento, doc: TextDocument): Promise; + showEditor(context: ContextT, sizeLimit?: number): Promise; + dispose(): Promise; +} + diff --git a/ui/package.json b/ui/package.json index 69b29ac667..52303883f0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "vscode-azureextensionui", "author": "Microsoft Corporation", - "version": "0.3.1", + "version": "0.4.0", "description": "Common UI tools for developing Azure extensions for VS Code", "tags": [ "azure", @@ -31,12 +31,14 @@ }, "dependencies": { "azure-arm-resource": "^3.0.0-preview", + "fs-extra": "^4.0.3", "ms-rest": "^2.2.2", "ms-rest-azure": "^2.4.4", "opn": "^5.1.0", "vscode-nls": "^2.0.2" }, "devDependencies": { + "@types/fs-extra": "^4.0.6", "typescript": "^2.5.3", "tslint": "^5.7.0", "tslint-microsoft-contrib": "5.0.1", diff --git a/ui/src/BaseEditor.ts b/ui/src/BaseEditor.ts new file mode 100644 index 0000000000..87f4891aad --- /dev/null +++ b/ui/src/BaseEditor.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fse from 'fs-extra'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { DialogResponses } from './DialogResponses' ; +import { UserCancelledError } from './errors'; +import { localize } from "./localize"; +import { createTemporaryFile } from './utils/createTemporaryFile'; + +// tslint:disable-next-line:no-unsafe-any +export abstract class BaseEditor implements vscode.Disposable { + private fileMap: { [key: string]: [vscode.TextDocument, ContextT] } = {}; + private ignoreSave: boolean = false; + + constructor(private readonly showSavePromptKey: string, private readonly outputChannel?: vscode.OutputChannel) { + } + + public abstract getData(context: ContextT): Promise; + public abstract updateData(context: ContextT, data: string): Promise; + public abstract getFilename(context: ContextT): Promise; + public abstract getSaveConfirmationText(context: ContextT): Promise; + public abstract getSize(context: ContextT): Promise; + + public async showEditor(context: ContextT, sizeLimit?: number /* in Megabytes */): Promise { + const fileName: string = await this.getFilename(context); + + this.appendLineToOutput(localize('opening', 'Opening "{0}"...', fileName)); + if (sizeLimit !== undefined) { + const size: number = await this.getSize(context); + if (size > sizeLimit) { + const message: string = localize('tooLargeError', '"{0}" is too large to download.', fileName); + throw new Error(message); + } + } + + const localFilePath: string = await createTemporaryFile(fileName); + const document: vscode.TextDocument = await vscode.workspace.openTextDocument(localFilePath); + this.fileMap[localFilePath] = [document, context]; + const data: string = await this.getData(context); + const textEditor: vscode.TextEditor = await vscode.window.showTextDocument(document); + await this.updateEditor(data, textEditor); + } + + public async updateMatchingContext(doc: vscode.Uri): Promise { + const filePath: string | undefined = Object.keys(this.fileMap).find((fsPath: string) => path.relative(doc.fsPath, fsPath) === ''); + if (filePath) { + const [textDocument, context]: [vscode.TextDocument, ContextT] = this.fileMap[filePath]; + await this.updateRemote(context, textDocument); + } + } + + public async dispose(): Promise { + Object.keys(this.fileMap).forEach(async (key: string) => await fse.remove(path.dirname(key))); + } + + public async onDidSaveTextDocument(globalState: vscode.Memento, doc: vscode.TextDocument): Promise { + const filePath: string | undefined = Object.keys(this.fileMap).find((fsPath: string) => path.relative(doc.uri.fsPath, fsPath) === ''); + if (!this.ignoreSave && filePath) { + const context: ContextT = this.fileMap[filePath][1]; + const showSaveWarning: boolean | undefined = vscode.workspace.getConfiguration().get(this.showSavePromptKey); + + if (showSaveWarning) { + const message: string = await this.getSaveConfirmationText(context); + const result: vscode.MessageItem | undefined = await vscode.window.showWarningMessage(message, DialogResponses.upload, DialogResponses.dontWarn, DialogResponses.dontUpload); + if (result === DialogResponses.dontWarn) { + await vscode.workspace.getConfiguration().update(this.showSavePromptKey, false, vscode.ConfigurationTarget.Global); + await globalState.update(this.showSavePromptKey, true); + } else if (result === DialogResponses.dontUpload) { + throw new UserCancelledError(); + } + } + await this.updateRemote(context, doc); + } + } + + protected appendLineToOutput(value: string): void { + if (!!this.outputChannel) { + this.outputChannel.appendLine(value); + this.outputChannel.show(true); + } + } + + private async updateRemote(context: ContextT, doc: vscode.TextDocument): Promise { + const filename: string = await this.getFilename(context); + this.appendLineToOutput(localize('updating', 'Updating "{0}" ...', filename)); + const updatedData: string = await this.updateData(context, doc.getText()); + this.appendLineToOutput(localize('done', 'Updated "{0}".', filename)); + await this.updateEditor(updatedData, vscode.window.activeTextEditor); + } + + private async updateEditor(data: string, textEditor?: vscode.TextEditor): Promise { + if (!!textEditor) { + await BaseEditor.writeToEditor(textEditor, data); + this.ignoreSave = true; + try { + await textEditor.document.save(); + } finally { + this.ignoreSave = false; + } + } + } + // tslint:disable-next-line:member-ordering + private static async writeToEditor(editor: vscode.TextEditor, data: string): Promise { + await editor.edit((editBuilder: vscode.TextEditorEdit) => { + if (editor.document.lineCount > 0) { + const lastLine: vscode.TextLine = editor.document.lineAt(editor.document.lineCount - 1); + editBuilder.delete(new vscode.Range(new vscode.Position(0, 0), new vscode.Position(lastLine.range.start.line, lastLine.range.end.character))); + } + editBuilder.insert(new vscode.Position(0, 0), data); + }); + } +} diff --git a/ui/src/DialogResponses.ts b/ui/src/DialogResponses.ts new file mode 100644 index 0000000000..3bd0bf946e --- /dev/null +++ b/ui/src/DialogResponses.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MessageItem } from 'vscode'; +import { localize } from './localize'; + +export namespace DialogResponses { + export const upload: MessageItem = { title: localize('upload', "Upload") }; + export const dontWarn: MessageItem = { title: localize('dontwarn', "Upload, don't warn again") }; + export const dontUpload: MessageItem = { title: localize('dontUpload', "Don't Upload"), isCloseAffordance: true }; + export const OK: MessageItem = { title: localize('OK', 'OK') }; +} diff --git a/ui/src/index.ts b/ui/src/index.ts index f6cdb2548f..ecb90c2657 100644 --- a/ui/src/index.ts +++ b/ui/src/index.ts @@ -5,3 +5,4 @@ export { AzureTreeDataProvider } from './treeDataProvider/AzureTreeDataProvider'; export { UserCancelledError } from './errors'; +export { BaseEditor } from './BaseEditor'; diff --git a/ui/src/utils/createTemporaryFile.ts b/ui/src/utils/createTemporaryFile.ts new file mode 100644 index 0000000000..45fb70509c --- /dev/null +++ b/ui/src/utils/createTemporaryFile.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as crypto from "crypto"; +import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; + +export async function createTemporaryFile(fileName: string): Promise { + const randomFolderNameLength: number = 12; + const buffer: Buffer = crypto.randomBytes(Math.ceil(randomFolderNameLength / 2)); + const folderName: string = buffer.toString('hex').slice(0, randomFolderNameLength); + const filePath: string = path.join(os.tmpdir(), folderName, fileName); + await fse.ensureFile(filePath); + return filePath; +} diff --git a/ui/thirdpartynotices.txt b/ui/thirdpartynotices.txt index f07d08e028..6454340163 100644 --- a/ui/thirdpartynotices.txt +++ b/ui/thirdpartynotices.txt @@ -6,6 +6,7 @@ open source projects along with the license information below. We acknowledge an are grateful to these developers for their contribution to open source. 1. opn version 5.1.0 (https://github.com/sindresorhus/opn +2. fs-extra (https://github.com/jprichardson/node-fs-extra) opn NOTICES BEGIN HERE ============================= @@ -34,3 +35,25 @@ THE SOFTWARE. END OF opn NOTICES AND INFORMATION ========================================= + +fs-extra NOTICES BEGIN HERE +============================= + +(The MIT License) + +Copyright (c) 2011-2017 JP Richardson + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +(the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, + merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +END OF fs-extra NOTICES AND INFORMATION +================================== diff --git a/ui/tslint.json b/ui/tslint.json index 823788f393..2a6742af8f 100644 --- a/ui/tslint.json +++ b/ui/tslint.json @@ -156,7 +156,7 @@ "no-inferrable-types": false, "no-multiline-string": true, "no-null-keyword": false, - "no-parameter-properties": true, + "no-parameter-properties": false, // changed "no-relative-imports": false, // changed "no-require-imports": true, "no-shadowed-variable": true,