mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-02-16 10:58:30 -05:00
Open books from Github (#10670)
* Add open book option in UI * Add dropdowns option on dialog * Add flow logic * Fetch releases and validate URL * Add class for github books and shared file books * Change code structure * Unblock local copy + stack overflows * Download books from github * Remove unused files * Clean code and use the openNotebookFolder command to open remote book * Checkpoint * Refactor remote book dialog model to only hold data * Remove ApiWrapper and refactor createlocalcopy method * Use sinon js framework instead of typemoq for testing remotebookController * Remove api wrapper * Add some tests * Add more tests and address pr comments * Address PR comments * Fix remotebook broken tests * Add download location in output channel and use openBook command * Address PR comments * Fix typos, print error message and remove failing test * Print error message * Separate tests in different files * Declare controller variable inside extension.tst Co-authored-by: chlafreniere <hichise@gmail.com>
This commit is contained in:
96
extensions/notebook/src/book/githubRemoteBook.ts
Normal file
96
extensions/notebook/src/book/githubRemoteBook.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as request from 'request';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as loc from '../common/localizedConstants';
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as zip from 'adm-zip';
|
||||
import * as tar from 'tar';
|
||||
import * as utils from '../common/utils';
|
||||
import { RemoteBook } from './remoteBook';
|
||||
import { IAsset } from './remoteBookController';
|
||||
import * as constants from '../common/constants';
|
||||
|
||||
export class GitHubRemoteBook extends RemoteBook {
|
||||
constructor(public remotePath: URL, public outputChannel: vscode.OutputChannel, protected _asset: IAsset) {
|
||||
super(remotePath, outputChannel, _asset);
|
||||
}
|
||||
|
||||
public async createLocalCopy(): Promise<void> {
|
||||
this.outputChannel.show(true);
|
||||
this.setLocalPath();
|
||||
this.outputChannel.appendLine(loc.msgDownloadLocation(this._localPath.href));
|
||||
this.outputChannel.appendLine(loc.msgRemoteBookDownloadProgress);
|
||||
this.createDirectory();
|
||||
let notebookConfig = vscode.workspace.getConfiguration(constants.notebookConfigKey);
|
||||
let downloadTimeout = notebookConfig[constants.remoteBookDownloadTimeout];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let options = {
|
||||
headers: {
|
||||
'User-Agent': 'request',
|
||||
'timeout': downloadTimeout
|
||||
}
|
||||
};
|
||||
let downloadRequest = request.get(this._asset.browserDownloadUrl.href, options)
|
||||
.on('error', (error) => {
|
||||
this.outputChannel.appendLine(loc.msgRemoteBookDownloadError);
|
||||
this.outputChannel.appendLine(error.message);
|
||||
reject(error);
|
||||
})
|
||||
.on('response', (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
this.outputChannel.appendLine(loc.msgRemoteBookDownloadError);
|
||||
return reject(new Error(loc.httpRequestError(response.statusCode, response.statusMessage)));
|
||||
}
|
||||
});
|
||||
let remoteBookFullPath = new URL(this._localPath.href.concat('.zip'));
|
||||
downloadRequest.pipe(fs.createWriteStream(remoteBookFullPath.href))
|
||||
.on('close', async () => {
|
||||
resolve(this.extractFiles(remoteBookFullPath));
|
||||
})
|
||||
.on('error', (error) => {
|
||||
this.outputChannel.appendLine(loc.msgRemoteBookDownloadError);
|
||||
this.outputChannel.appendLine(error.message);
|
||||
reject(error);
|
||||
downloadRequest.abort();
|
||||
});
|
||||
});
|
||||
}
|
||||
public async createDirectory(): Promise<void> {
|
||||
let fileName = this._asset.book.concat('-').concat(this._asset.version).concat('-').concat(this._asset.language);
|
||||
this._localPath = new URL(path.join(this._localPath.href, fileName));
|
||||
try {
|
||||
let exists = await fs.pathExists(this._localPath.href);
|
||||
if (exists) {
|
||||
await fs.remove(this._localPath.href);
|
||||
}
|
||||
await fs.promises.mkdir(this._localPath.href);
|
||||
} catch (error) {
|
||||
this.outputChannel.appendLine(loc.msgRemoteBookDirectoryError);
|
||||
this.outputChannel.appendLine(error.message);
|
||||
}
|
||||
}
|
||||
public async extractFiles(remoteBookFullPath: URL): Promise<void> {
|
||||
try {
|
||||
if (utils.getOSPlatform() === utils.Platform.Windows || utils.getOSPlatform() === utils.Platform.Mac) {
|
||||
let zippedFile = new zip(remoteBookFullPath.href);
|
||||
zippedFile.extractAllTo(this._localPath.href);
|
||||
} else {
|
||||
tar.extract({ file: remoteBookFullPath.href, cwd: this._localPath.href }).catch(error => {
|
||||
this.outputChannel.appendLine(loc.msgRemoteBookUnpackingError);
|
||||
this.outputChannel.appendLine(error.message);
|
||||
});
|
||||
}
|
||||
await fs.promises.unlink(remoteBookFullPath.href);
|
||||
this.outputChannel.appendLine(loc.msgRemoteBookDownloadComplete);
|
||||
vscode.commands.executeCommand('notebook.command.openNotebookFolder', this._localPath.href, undefined, true);
|
||||
}
|
||||
catch (err) {
|
||||
this.outputChannel.appendLine(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
extensions/notebook/src/book/remoteBook.ts
Normal file
30
extensions/notebook/src/book/remoteBook.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 utils from '../common/utils';
|
||||
import { IAsset } from './remoteBookController';
|
||||
|
||||
export abstract class RemoteBook {
|
||||
protected _localPath: URL;
|
||||
|
||||
constructor(public remotePath: URL, public outputChannel: vscode.OutputChannel, protected _asset?: IAsset) {
|
||||
this.remotePath = remotePath;
|
||||
}
|
||||
|
||||
public async abstract createLocalCopy(): Promise<void>;
|
||||
|
||||
public setLocalPath(): void {
|
||||
// Save directory on User directory
|
||||
if (vscode.workspace.workspaceFolders !== undefined) {
|
||||
// Get workspace root path
|
||||
let folders = vscode.workspace.workspaceFolders;
|
||||
this._localPath = new URL(folders[0].uri.fsPath);
|
||||
} else {
|
||||
//If no workspace folder is opened then path is Users directory
|
||||
this._localPath = new URL(utils.getUserHome());
|
||||
}
|
||||
}
|
||||
}
|
||||
142
extensions/notebook/src/book/remoteBookController.ts
Normal file
142
extensions/notebook/src/book/remoteBookController.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as request from 'request';
|
||||
import * as loc from '../common/localizedConstants';
|
||||
import * as utils from '../common/utils';
|
||||
import * as vscode from 'vscode';
|
||||
import { RemoteBookDialogModel } from '../dialog/remoteBookDialogModel';
|
||||
import { GitHubRemoteBook } from '../book/githubRemoteBook';
|
||||
import { SharedRemoteBook } from '../book/sharedRemoteBook';
|
||||
|
||||
const assetNameRE = /([a-zA-Z0-9]+)(?:-|_)([a-zA-Z0-9.]+)(?:-|_)([a-zA-Z0-9]+).(zip|tar.gz|tgz)/;
|
||||
|
||||
export class RemoteBookController {
|
||||
constructor(public model: RemoteBookDialogModel, public outputChannel: vscode.OutputChannel) {
|
||||
}
|
||||
|
||||
public async setRemoteBook(url: URL, remoteLocation: string, asset?: IAsset): Promise<void> {
|
||||
if (remoteLocation === 'GitHub') {
|
||||
this.model.remoteBook = new GitHubRemoteBook(url, this.outputChannel, asset);
|
||||
} else {
|
||||
this.model.remoteBook = new SharedRemoteBook(url, this.outputChannel);
|
||||
}
|
||||
return await this.model.remoteBook.createLocalCopy();
|
||||
}
|
||||
|
||||
public async getReleases(url?: URL): Promise<IRelease[]> {
|
||||
if (url) {
|
||||
this.model.releases = [];
|
||||
let options = {
|
||||
headers: {
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
};
|
||||
return new Promise<IRelease[]>((resolve, reject) => {
|
||||
request.get(url.href, options, (error, response, body) => {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
return reject(new Error(loc.httpRequestError(response.statusCode, response.statusMessage)));
|
||||
}
|
||||
|
||||
let releases = JSON.parse(body);
|
||||
let bookReleases: IRelease[] = [];
|
||||
if (releases?.length > 0) {
|
||||
let keys = Object.keys(releases);
|
||||
keys.forEach(key => {
|
||||
try {
|
||||
bookReleases.push({ name: releases[key].name, assetsUrl: new URL(releases[key].assets_url) });
|
||||
}
|
||||
catch (error) {
|
||||
return reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (bookReleases.length > 0) {
|
||||
this.model.releases = bookReleases;
|
||||
resolve(bookReleases);
|
||||
} else {
|
||||
return reject(new Error(loc.msgReleaseNotFound));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return this.model.releases;
|
||||
}
|
||||
}
|
||||
|
||||
public async getAssets(release?: IRelease): Promise<IAsset[]> {
|
||||
if (release) {
|
||||
let format: string[] = [];
|
||||
if (utils.getOSPlatform() === utils.Platform.Windows || utils.getOSPlatform() === utils.Platform.Mac) {
|
||||
format = ['zip'];
|
||||
} else {
|
||||
format = ['tar.gz', 'tgz'];
|
||||
}
|
||||
let options = {
|
||||
headers: {
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
};
|
||||
return new Promise<IAsset[]>((resolve, reject) => {
|
||||
request.get(release.assetsUrl.href, options, (error, response, body) => {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
return reject(new Error(loc.httpRequestError(response.statusCode, response.statusMessage)));
|
||||
}
|
||||
let assets = JSON.parse(body);
|
||||
let githubAssets: IAsset[] = [];
|
||||
if (assets) {
|
||||
let keys = Object.keys(assets);
|
||||
keys.forEach(key => {
|
||||
let asset = {} as IAsset;
|
||||
asset.url = new URL(assets[key].url);
|
||||
asset.name = assets[key].name;
|
||||
asset.browserDownloadUrl = new URL(assets[key].browser_download_url);
|
||||
let groupsRe = asset.name.match(assetNameRE);
|
||||
if (groupsRe) {
|
||||
asset.book = groupsRe[1];
|
||||
asset.version = groupsRe[2];
|
||||
asset.language = groupsRe[3];
|
||||
asset.format = groupsRe[4];
|
||||
if (format.includes(asset.format)) {
|
||||
githubAssets.push(asset);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
this.model.assets = githubAssets;
|
||||
if (githubAssets.length > 0) {
|
||||
resolve(githubAssets);
|
||||
}
|
||||
return reject(new Error(loc.msgBookNotFound));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return this.model.assets;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface IRelease {
|
||||
name: string;
|
||||
assetsUrl: URL;
|
||||
}
|
||||
|
||||
export interface IAsset {
|
||||
name: string;
|
||||
book: string;
|
||||
version: string;
|
||||
language: string;
|
||||
format: string;
|
||||
url: URL;
|
||||
browserDownloadUrl: URL;
|
||||
}
|
||||
16
extensions/notebook/src/book/sharedRemoteBook.ts
Normal file
16
extensions/notebook/src/book/sharedRemoteBook.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { RemoteBook } from '../book/remoteBook';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
|
||||
export class SharedRemoteBook extends RemoteBook {
|
||||
constructor(public remotePath: URL, public outputChannel: vscode.OutputChannel) {
|
||||
super(remotePath, outputChannel);
|
||||
}
|
||||
public async createLocalCopy(): Promise<void> {
|
||||
throw new Error('Not yet supported');
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { NotebookUtils } from './notebookUtils';
|
||||
import { BookTreeViewProvider } from '../book/bookTreeView';
|
||||
import { NavigationProviders, BOOKS_VIEWID, PROVIDED_BOOKS_VIEWID } from './constants';
|
||||
import { NavigationProviders, BOOKS_VIEWID, PROVIDED_BOOKS_VIEWID, extensionOutputChannelName } from './constants';
|
||||
|
||||
/**
|
||||
* Global context for the application
|
||||
@@ -16,6 +16,7 @@ export class AppContext {
|
||||
public readonly notebookUtils: NotebookUtils;
|
||||
public readonly bookTreeViewProvider: BookTreeViewProvider;
|
||||
public readonly providedBookTreeViewProvider: BookTreeViewProvider;
|
||||
public readonly outputChannel: vscode.OutputChannel;
|
||||
|
||||
constructor(public readonly extensionContext: vscode.ExtensionContext) {
|
||||
this.notebookUtils = new NotebookUtils();
|
||||
@@ -23,5 +24,6 @@ export class AppContext {
|
||||
let workspaceFolders = vscode.workspace.workspaceFolders?.slice() ?? [];
|
||||
this.bookTreeViewProvider = new BookTreeViewProvider(workspaceFolders, extensionContext, false, BOOKS_VIEWID, NavigationProviders.NotebooksNavigator);
|
||||
this.providedBookTreeViewProvider = new BookTreeViewProvider([], extensionContext, true, PROVIDED_BOOKS_VIEWID, NavigationProviders.ProvidedBooksNavigator);
|
||||
this.outputChannel = vscode.window.createOutputChannel(extensionOutputChannelName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as nls from 'vscode-nls';
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
// CONFIG VALUES ///////////////////////////////////////////////////////////
|
||||
export const extensionOutputChannel = 'Notebooks';
|
||||
export const extensionOutputChannelName = 'Notebooks';
|
||||
|
||||
// JUPYTER CONFIG //////////////////////////////////////////////////////////
|
||||
export const pythonBundleVersion = '0.0.1';
|
||||
@@ -18,6 +18,7 @@ export const existingPythonConfigKey = 'useExistingPython';
|
||||
export const notebookConfigKey = 'notebook';
|
||||
export const trustedBooksConfigKey = 'trustedBooks';
|
||||
export const maxBookSearchDepth = 'maxBookSearchDepth';
|
||||
export const remoteBookDownloadTimeout = 'remoteBookDownloadTimeout';
|
||||
|
||||
export const winPlatform = 'win32';
|
||||
|
||||
|
||||
@@ -39,3 +39,35 @@ export function openMarkdownError(resource: string, error: string): string { ret
|
||||
export function openUntitledNotebookError(resource: string, error: string): string { return localize('openUntitledNotebookError', "Open untitled notebook {0} as untitled failed: {1}", resource, error); }
|
||||
export function openExternalLinkError(resource: string, error: string): string { return localize('openExternalLinkError', "Open link {0} failed: {1}", resource, error); }
|
||||
export function closeBookError(resource: string, error: string): string { return localize('closeBookError', "Close book {0} failed: {1}", resource, error); }
|
||||
|
||||
// Remote Book dialog constants
|
||||
export const url = localize('url', "URL");
|
||||
export const repoUrl = localize('repoUrl', "Repository URL");
|
||||
export const location = localize('location', "Location");
|
||||
export const addRemoteBook = localize('addRemoteBook', "Add Remote Book");
|
||||
export const onGitHub = localize('onGitHub', "GitHub");
|
||||
export const onSharedFile = localize('onsharedFile', "Shared File");
|
||||
export const releases = localize('releases', "Releases");
|
||||
export const book = localize('book', "Book");
|
||||
export const version = localize('version', "Version");
|
||||
export const language = localize('language', "Language");
|
||||
export const booksNotFound = localize('booksNotFound', "No books are currently available on the provided link");
|
||||
export const urlGithubError = localize('urlGithubError', "The url provided is not a Github release url");
|
||||
export const search = localize('search', "Search");
|
||||
export const add = localize('add', "Add");
|
||||
export const close = localize('close', "Close");
|
||||
export const invalidTextPlaceholder = localize('invalidTextPlaceholder', "-");
|
||||
|
||||
// Remote Book Controller constants
|
||||
export const msgRemoteBookDownloadProgress = localize('msgRemoteBookDownloadProgress', "Remote Book download is in progress");
|
||||
export const msgRemoteBookDownloadComplete = localize('msgRemoteBookDownloadComplete', "Remote Book download is complete");
|
||||
export const msgRemoteBookDownloadError = localize('msgRemoteBookDownloadError', "Error while downloading remote Book");
|
||||
export const msgRemoteBookUnpackingError = localize('msgRemoteBookUnpackingError', "Error while decompressing remote Book");
|
||||
export const msgRemoteBookDirectoryError = localize('msgRemoteBookDirectoryError', "Error while creating remote Book directory");
|
||||
export const msgTaskName = localize('msgTaskName', "Downloading Remote Book");
|
||||
export const msgResourceNotFound = localize('msgResourceNotFound', "Resource not Found");
|
||||
export const msgBookNotFound = localize('msgBookNotFound', "Books not Found");
|
||||
export const msgReleaseNotFound = localize('msgReleaseNotFound', "Releases not Found");
|
||||
export const msgUndefinedAssetError = localize('msgUndefinedAssetError', "The selected book is not valid");
|
||||
export function httpRequestError(code: number, message: string): string { return localize('httpRequestError', "Http Request failed with error: {0} {1}", code, message); }
|
||||
export function msgDownloadLocation(downloadLocation: string): string { return localize('msgDownloadLocation', "Downloading to {0}", downloadLocation); }
|
||||
|
||||
@@ -269,6 +269,25 @@ export function debounce(delay: number): Function {
|
||||
});
|
||||
}
|
||||
|
||||
export function generateGuid(): string {
|
||||
let hexValues: string[] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'];
|
||||
let oct: string = '';
|
||||
let tmp: number;
|
||||
for (let a: number = 0; a < 4; a++) {
|
||||
tmp = (4294967296 * Math.random()) | 0;
|
||||
oct += hexValues[tmp & 0xF] +
|
||||
hexValues[tmp >> 4 & 0xF] +
|
||||
hexValues[tmp >> 8 & 0xF] +
|
||||
hexValues[tmp >> 12 & 0xF] +
|
||||
hexValues[tmp >> 16 & 0xF] +
|
||||
hexValues[tmp >> 20 & 0xF] +
|
||||
hexValues[tmp >> 24 & 0xF] +
|
||||
hexValues[tmp >> 28 & 0xF];
|
||||
}
|
||||
let clockSequenceHi: string = hexValues[8 + (Math.random() * 4) | 0];
|
||||
return oct.substr(0, 8) + '-' + oct.substr(9, 4) + '-4' + oct.substr(13, 3) + '-' + clockSequenceHi + oct.substr(16, 3) + '-' + oct.substr(19, 12);
|
||||
}
|
||||
|
||||
// PRIVATE HELPERS /////////////////////////////////////////////////////////
|
||||
function outputDataChunk(data: string | Buffer, outputChannel: vscode.OutputChannel, header: string): void {
|
||||
data.toString().split(/\r?\n/)
|
||||
|
||||
292
extensions/notebook/src/dialog/remoteBookDialog.ts
Normal file
292
extensions/notebook/src/dialog/remoteBookDialog.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as azdata from 'azdata';
|
||||
import * as loc from '../common/localizedConstants';
|
||||
import { RemoteBookController, IAsset } from '../book/remoteBookController';
|
||||
import * as utils from '../common/utils';
|
||||
|
||||
const tigerToolboxRepo = 'repos/microsoft/tigertoolbox';
|
||||
const urlGithubRE = /^(?:https:\/\/(?:github\.com|api\.github\.com\/repos)|(?:\/)?(?:\/)?repos)([\w-.?!=&%*+:@\/]*)/g;
|
||||
|
||||
function apiGitHub(url: string): string {
|
||||
return `https://api.github.com/${url}/releases`;
|
||||
}
|
||||
|
||||
function getRemoteLocationCategory(name: string): azdata.CategoryValue {
|
||||
if (name === loc.onGitHub) {
|
||||
return { name: name, displayName: loc.onGitHub };
|
||||
}
|
||||
return { name: name, displayName: loc.onSharedFile };
|
||||
}
|
||||
|
||||
export class RemoteBookDialog {
|
||||
|
||||
private dialog: azdata.window.Dialog;
|
||||
public view: azdata.ModelView;
|
||||
private formModel: azdata.FormContainer;
|
||||
private githubRepoDropdown: azdata.DropDownComponent;
|
||||
private remoteLocationDropdown: azdata.DropDownComponent;
|
||||
public releaseDropdown: azdata.DropDownComponent;
|
||||
private searchButton: azdata.ButtonComponent;
|
||||
public bookDropdown: azdata.DropDownComponent;
|
||||
public versionDropdown: azdata.DropDownComponent;
|
||||
public languageDropdown: azdata.DropDownComponent;
|
||||
private _remoteTypes: azdata.CategoryValue[];
|
||||
|
||||
constructor(public controller: RemoteBookController) {
|
||||
}
|
||||
|
||||
public async createDialog(): Promise<void> {
|
||||
this.dialog = azdata.window.createModelViewDialog(loc.addRemoteBook);
|
||||
this.dialog.registerContent(async view => {
|
||||
this.view = view;
|
||||
|
||||
this.remoteLocationDropdown = this.view.modelBuilder.dropDown().withProperties({
|
||||
values: this.remoteLocationCategories,
|
||||
value: '',
|
||||
editable: false,
|
||||
}).component();
|
||||
|
||||
this.remoteLocationDropdown.onValueChanged(e => this.onRemoteLocationChanged());
|
||||
|
||||
this.githubRepoDropdown = this.view.modelBuilder.dropDown().withProperties({
|
||||
values: [tigerToolboxRepo],
|
||||
value: '',
|
||||
editable: true,
|
||||
fireOnTextChange: true,
|
||||
}).component();
|
||||
|
||||
this.searchButton = this.view.modelBuilder.button().withProperties<azdata.ButtonProperties>({
|
||||
label: loc.search,
|
||||
title: loc.search,
|
||||
width: '200px'
|
||||
}).component();
|
||||
this.searchButton.onDidClick(async () => await this.validate());
|
||||
|
||||
this.releaseDropdown = this.view.modelBuilder.dropDown()
|
||||
.withProperties({
|
||||
values: [],
|
||||
value: '',
|
||||
enabled: false
|
||||
}).component();
|
||||
|
||||
this.releaseDropdown.onValueChanged(async () => await this.getAssets());
|
||||
|
||||
this.bookDropdown = this.view.modelBuilder.dropDown().withProperties({
|
||||
values: [],
|
||||
value: '',
|
||||
editable: false,
|
||||
}).component();
|
||||
|
||||
this.bookDropdown.onValueChanged(async () => await this.fillVersionDropdown());
|
||||
|
||||
this.versionDropdown = this.view.modelBuilder.dropDown().withProperties({
|
||||
values: [],
|
||||
value: '',
|
||||
editable: false,
|
||||
}).component();
|
||||
|
||||
this.versionDropdown.onValueChanged(async () => await this.fillLanguageDropdown());
|
||||
|
||||
this.languageDropdown = this.view.modelBuilder.dropDown().withProperties({
|
||||
values: [],
|
||||
value: '',
|
||||
editable: false,
|
||||
}).component();
|
||||
|
||||
this.languageDropdown.onValueChanged(async () => this.checkValues());
|
||||
this.setFieldsToEmpty();
|
||||
|
||||
this.formModel = this.view.modelBuilder.formContainer()
|
||||
.withFormItems([{
|
||||
components: [
|
||||
{
|
||||
component: this.remoteLocationDropdown,
|
||||
title: loc.location,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
component: this.githubRepoDropdown,
|
||||
title: loc.repoUrl,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
component: this.searchButton,
|
||||
title: ''
|
||||
},
|
||||
{
|
||||
component: this.releaseDropdown,
|
||||
title: loc.releases,
|
||||
},
|
||||
{
|
||||
component: this.bookDropdown,
|
||||
title: loc.book,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
component: this.versionDropdown,
|
||||
title: loc.version,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
component: this.languageDropdown,
|
||||
title: loc.language,
|
||||
required: true
|
||||
},
|
||||
],
|
||||
title: ''
|
||||
}]).withLayout({ width: '100%' }).component();
|
||||
await this.view.initializeModel(this.formModel);
|
||||
});
|
||||
this.dialog.okButton.enabled = false;
|
||||
this.dialog.okButton.label = loc.add;
|
||||
this.dialog.cancelButton.label = loc.close;
|
||||
this.dialog.registerCloseValidator(async () => await this.download());
|
||||
azdata.window.openDialog(this.dialog);
|
||||
}
|
||||
|
||||
private async setFieldsToEmpty(): Promise<void> {
|
||||
await this.bookDropdown.updateProperties({
|
||||
values: [loc.invalidTextPlaceholder],
|
||||
value: loc.invalidTextPlaceholder
|
||||
});
|
||||
await this.versionDropdown.updateProperties({
|
||||
values: [loc.invalidTextPlaceholder],
|
||||
value: loc.invalidTextPlaceholder
|
||||
});
|
||||
await this.languageDropdown.updateProperties({
|
||||
values: [loc.invalidTextPlaceholder],
|
||||
value: loc.invalidTextPlaceholder
|
||||
});
|
||||
this.dialog.okButton.enabled = false;
|
||||
}
|
||||
|
||||
private get remoteLocationValue(): string {
|
||||
return (<azdata.CategoryValue>this.remoteLocationDropdown.value).name;
|
||||
}
|
||||
|
||||
public onRemoteLocationChanged(): void {
|
||||
if (this.controller.getReleases() !== undefined && this.remoteLocationValue === loc.onGitHub) {
|
||||
this.releaseDropdown.enabled = true;
|
||||
} else {
|
||||
this.releaseDropdown.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async validate(): Promise<void> {
|
||||
try {
|
||||
let url = utils.getDropdownValue(this.githubRepoDropdown);
|
||||
url = url.trim().toLowerCase();
|
||||
if (this.remoteLocationValue === loc.onGitHub && url.length > 0) {
|
||||
//get the first group to extract /owner/repo/releases format
|
||||
let groupsRe = url.match(urlGithubRE);
|
||||
if (groupsRe?.length > 0) {
|
||||
url = apiGitHub(groupsRe[0]);
|
||||
let releases = await this.controller.getReleases(new URL(url));
|
||||
if (releases) {
|
||||
this.releaseDropdown.enabled = true;
|
||||
await this.fillReleasesDropdown();
|
||||
this.setFieldsToEmpty();
|
||||
}
|
||||
} else {
|
||||
throw new Error(loc.urlGithubError);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
await this.fillReleasesDropdown();
|
||||
this.setFieldsToEmpty();
|
||||
this.showErrorMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async getAssets(): Promise<void> {
|
||||
try {
|
||||
if (this.remoteLocationValue === loc.onGitHub) {
|
||||
let releases = await this.controller.getReleases();
|
||||
let selected_release = releases.filter(release =>
|
||||
release.name === this.releaseDropdown.value);
|
||||
let assets = await this.controller.getAssets(selected_release[0]);
|
||||
if (assets?.length > 0) {
|
||||
this.bookDropdown.values = ['-'].concat([...new Set(assets.map(asset => asset.book))]);
|
||||
}
|
||||
this.checkValues();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
this.setFieldsToEmpty();
|
||||
this.showErrorMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async download(): Promise<boolean> {
|
||||
try {
|
||||
if (this.remoteLocationValue === loc.onGitHub) {
|
||||
let selected_asset = await this.getSelectedAsset();
|
||||
if (!selected_asset) {
|
||||
throw new Error(loc.msgUndefinedAssetError);
|
||||
}
|
||||
await this.controller.setRemoteBook(selected_asset.url, this.remoteLocationValue, selected_asset);
|
||||
} else {
|
||||
let url = utils.getDropdownValue(this.githubRepoDropdown);
|
||||
let newUrl = new URL(url);
|
||||
await this.controller.setRemoteBook(newUrl, this.remoteLocationValue);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
this.showErrorMessage(error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async fillReleasesDropdown(): Promise<void> {
|
||||
this.releaseDropdown.values = ['-'].concat((await this.controller.getReleases()).map(release => release.name));
|
||||
}
|
||||
|
||||
public async fillVersionDropdown(): Promise<void> {
|
||||
let filtered_assets = (await this.controller.getAssets()).filter(asset => asset.book === this.bookDropdown.value);
|
||||
this.versionDropdown.values = ['-'].concat(filtered_assets.map(asset => asset.version));
|
||||
this.checkValues();
|
||||
}
|
||||
|
||||
public async fillLanguageDropdown(): Promise<void> {
|
||||
let filtered_assets = (await this.controller.getAssets()).filter(asset => asset.book === this.bookDropdown.value &&
|
||||
asset.version === this.versionDropdown.value);
|
||||
this.languageDropdown.values = ['-'].concat(filtered_assets.map(asset => asset.language));
|
||||
this.checkValues();
|
||||
}
|
||||
|
||||
public async getSelectedAsset(): Promise<IAsset> {
|
||||
let lang = this.languageDropdown.value;
|
||||
let book = this.bookDropdown.value;
|
||||
let version = this.versionDropdown.value;
|
||||
return (await this.controller.getAssets()).filter(asset => asset.book === book && asset.version === version && asset.language === lang)[0];
|
||||
}
|
||||
|
||||
public checkValues(): void {
|
||||
if (this.languageDropdown.value !== loc.invalidTextPlaceholder && this.versionDropdown.value !== loc.invalidTextPlaceholder &&
|
||||
this.bookDropdown.value !== loc.invalidTextPlaceholder) {
|
||||
this.dialog.okButton.enabled = true;
|
||||
} else {
|
||||
this.dialog.okButton.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public get remoteLocationCategories(): azdata.CategoryValue[] {
|
||||
if (!this._remoteTypes) {
|
||||
this._remoteTypes = [getRemoteLocationCategory(loc.onGitHub)];
|
||||
}
|
||||
return this._remoteTypes;
|
||||
}
|
||||
|
||||
public showErrorMessage(message: string): void {
|
||||
this.dialog.message = {
|
||||
text: message,
|
||||
level: azdata.window.MessageLevel.Error
|
||||
};
|
||||
}
|
||||
}
|
||||
50
extensions/notebook/src/dialog/remoteBookDialogModel.ts
Normal file
50
extensions/notebook/src/dialog/remoteBookDialogModel.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { RemoteBook } from '../book/remoteBook';
|
||||
import { IRelease, IAsset } from '../book/remoteBookController';
|
||||
|
||||
export class RemoteBookDialogModel {
|
||||
private _remoteLocation: string;
|
||||
private _releases: IRelease[];
|
||||
private _assets: IAsset[];
|
||||
private _book: RemoteBook;
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
public get remoteLocation(): string {
|
||||
return this._remoteLocation;
|
||||
}
|
||||
|
||||
public set remoteLocation(location: string) {
|
||||
this._remoteLocation = location;
|
||||
}
|
||||
|
||||
public get releases(): IRelease[] {
|
||||
return this._releases;
|
||||
}
|
||||
|
||||
public set releases(newReleases: IRelease[]) {
|
||||
this._releases = newReleases;
|
||||
}
|
||||
|
||||
public get assets(): IAsset[] {
|
||||
return this._assets;
|
||||
}
|
||||
|
||||
public set assets(newAssets: IAsset[]) {
|
||||
this._assets = newAssets;
|
||||
}
|
||||
|
||||
public get remoteBook(): RemoteBook {
|
||||
return this._book;
|
||||
}
|
||||
|
||||
public set remoteBook(newBook: RemoteBook) {
|
||||
this._book = newBook;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import { IExtensionApi, IPackageManageProvider } from './types';
|
||||
import { CellType } from './contracts/content';
|
||||
import { NotebookUriHandler } from './protocol/notebookUriHandler';
|
||||
import { BuiltInCommands, unsavedBooksContextKey } from './common/constants';
|
||||
import { RemoteBookController } from './book/remoteBookController';
|
||||
import { RemoteBookDialog } from './dialog/remoteBookDialog';
|
||||
import { RemoteBookDialogModel } from './dialog/remoteBookDialogModel';
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
@@ -36,7 +39,6 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi
|
||||
extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.closeBook', (book: any) => bookTreeViewProvider.closeBook(book)));
|
||||
extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.closeNotebook', (book: any) => bookTreeViewProvider.closeBook(book)));
|
||||
extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.openNotebookFolder', (folderPath?: string, urlToOpen?: string, showPreview?: boolean,) => bookTreeViewProvider.openNotebookFolder(folderPath, urlToOpen, showPreview)));
|
||||
|
||||
extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.createBook', async () => {
|
||||
let untitledFileName: vscode.Uri = vscode.Uri.parse(`untitled:${createBookPath}`);
|
||||
await vscode.workspace.openTextDocument(createBookPath).then((document) => {
|
||||
@@ -47,6 +49,15 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
let model = new RemoteBookDialogModel();
|
||||
let remoteBookController = new RemoteBookController(model, appContext.outputChannel);
|
||||
|
||||
extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.openRemoteBook', async () => {
|
||||
let dialog = new RemoteBookDialog(remoteBookController);
|
||||
dialog.createDialog();
|
||||
}));
|
||||
|
||||
extensionContext.subscriptions.push(vscode.commands.registerCommand('_notebook.command.new', async (context?: azdata.ConnectedContext) => {
|
||||
let connectionProfile: azdata.IConnectionProfile = undefined;
|
||||
if (context && context.connectionProfile) {
|
||||
|
||||
@@ -40,13 +40,11 @@ export class JupyterController implements vscode.Disposable {
|
||||
private _serverInstanceFactory: ServerInstanceFactory = new ServerInstanceFactory();
|
||||
private _packageManageProviders = new Map<string, IPackageManageProvider>();
|
||||
|
||||
private outputChannel: vscode.OutputChannel;
|
||||
private prompter: IPrompter;
|
||||
private _notebookProvider: JupyterNotebookProvider;
|
||||
|
||||
constructor(private appContext: AppContext) {
|
||||
this.prompter = new CodeAdapter();
|
||||
this.outputChannel = vscode.window.createOutputChannel(constants.extensionOutputChannel);
|
||||
}
|
||||
|
||||
public get extensionContext(): vscode.ExtensionContext {
|
||||
@@ -65,7 +63,7 @@ export class JupyterController implements vscode.Disposable {
|
||||
public async activate(): Promise<boolean> {
|
||||
this._jupyterInstallation = new JupyterServerInstallation(
|
||||
this.extensionContext.extensionPath,
|
||||
this.outputChannel);
|
||||
this.appContext.outputChannel);
|
||||
await this._jupyterInstallation.configurePackagePaths();
|
||||
IconPathHelper.setExtensionContext(this.extensionContext);
|
||||
|
||||
|
||||
121
extensions/notebook/src/test/book/remoteBookController.test.ts
Normal file
121
extensions/notebook/src/test/book/remoteBookController.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { RemoteBookDialogModel } from '../../dialog/remoteBookDialogModel';
|
||||
import { IRelease, RemoteBookController } from '../../book/remoteBookController';
|
||||
import * as should from 'should';
|
||||
import * as request from 'request';
|
||||
import * as sinon from 'sinon';
|
||||
import * as vscode from 'vscode';
|
||||
import { MockExtensionContext } from '../common/stubs';
|
||||
import { AppContext } from '../../common/appContext';
|
||||
import * as loc from '../../common/localizedConstants';
|
||||
|
||||
describe('Remote Book Controller', function () {
|
||||
let mockExtensionContext: vscode.ExtensionContext = new MockExtensionContext();
|
||||
let appContext = new AppContext(mockExtensionContext);
|
||||
let model = new RemoteBookDialogModel();
|
||||
let controller = new RemoteBookController(model, appContext.outputChannel);
|
||||
let getStub : sinon.SinonStub;
|
||||
|
||||
beforeEach(function (): void {
|
||||
getStub = sinon.stub(request, 'get');
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('Verify that errorMessage is thrown, when fetchReleases call returns empty', async function (): Promise<void> {
|
||||
let expectedBody = JSON.stringify([]);
|
||||
let expectedURL = new URL('https://api.github.com/repos/microsoft/test/releases');
|
||||
getStub.yields(null, { statusCode: 200 }, expectedBody);
|
||||
|
||||
try {
|
||||
await controller.getReleases(expectedURL);
|
||||
}
|
||||
catch (err) {
|
||||
should(err.message).be.equals(loc.msgReleaseNotFound);
|
||||
should(model.releases.length).be.equal(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('Should get the books', async function (): Promise<void> {
|
||||
let expectedBody = JSON.stringify([
|
||||
{
|
||||
url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/1',
|
||||
name: 'test-1.1-EN.zip',
|
||||
browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/1/test-1.1-EN.zip',
|
||||
|
||||
},
|
||||
{
|
||||
url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/2',
|
||||
name: 'test-1.1-ES.zip',
|
||||
browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/2/test-1.1-ES.zip',
|
||||
},
|
||||
{
|
||||
url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/1',
|
||||
name: 'test-1.1-EN.tgz',
|
||||
browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/1/test-1.1-EN.tgz',
|
||||
|
||||
},
|
||||
{
|
||||
url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/2',
|
||||
name: 'test-1.1-ES.tar.gz',
|
||||
browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/2/test-1.1-ES.tar.gz',
|
||||
},
|
||||
{
|
||||
url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/3',
|
||||
name: 'test-1.1-FR.tgz',
|
||||
browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/1/test-1.1-FR.tgz',
|
||||
}
|
||||
]);
|
||||
let expectedURL = new URL('https://api.github.com/repos/microsoft/test/releases/1/assets');
|
||||
let expectedRelease: IRelease = {
|
||||
name: 'Test Release',
|
||||
assetsUrl: expectedURL
|
||||
};
|
||||
getStub.yields(null, { statusCode: 200 }, expectedBody);
|
||||
|
||||
let result = await controller.getAssets(expectedRelease);
|
||||
should(result.length).be.above(0, 'Result should contain assets');
|
||||
result.forEach(asset => {
|
||||
should(asset).have.property('name');
|
||||
should(asset).have.property('url');
|
||||
should(asset).have.property('browserDownloadUrl');
|
||||
});
|
||||
});
|
||||
|
||||
it('Should throw an error if the book object does not follow the name-version-lang format', async function (): Promise<void> {
|
||||
let expectedBody = JSON.stringify([
|
||||
{
|
||||
url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/1',
|
||||
name: 'test-1.1.zip',
|
||||
browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/1/test-1.1.zip',
|
||||
|
||||
},
|
||||
{
|
||||
url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/2',
|
||||
name: 'test-1.2.zip',
|
||||
browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/1/test-1.2.zip',
|
||||
},
|
||||
]);
|
||||
let expectedURL = new URL('https://api.github.com/repos/microsoft/test/releases/1/assets');
|
||||
let expectedRelease: IRelease = {
|
||||
name: 'Test Release',
|
||||
assetsUrl: expectedURL
|
||||
};
|
||||
getStub.yields(null, { statusCode: 200 }, expectedBody);
|
||||
|
||||
try {
|
||||
await controller.getAssets(expectedRelease);
|
||||
}
|
||||
catch (err) {
|
||||
should(err.message).be.equals(loc.msgBookNotFound);
|
||||
should(model.releases.length).be.equal(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
29
extensions/notebook/src/test/book/remoteBookDialog.test.ts
Normal file
29
extensions/notebook/src/test/book/remoteBookDialog.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { RemoteBookDialog } from '../../dialog/remoteBookDialog';
|
||||
import { RemoteBookDialogModel } from '../../dialog/remoteBookDialogModel';
|
||||
import { RemoteBookController } from '../../book/remoteBookController';
|
||||
import * as sinon from 'sinon';
|
||||
import * as vscode from 'vscode';
|
||||
import { MockExtensionContext } from '../common/stubs';
|
||||
import { AppContext } from '../../common/appContext';
|
||||
import * as azdata from 'azdata';
|
||||
import * as should from 'should';
|
||||
|
||||
describe('Add Remote Book Dialog', function () {
|
||||
let mockExtensionContext: vscode.ExtensionContext = new MockExtensionContext();
|
||||
let appContext = new AppContext(mockExtensionContext);
|
||||
let model = new RemoteBookDialogModel();
|
||||
let controller = new RemoteBookController(model, appContext.outputChannel);
|
||||
let dialog = new RemoteBookDialog(controller);
|
||||
|
||||
it('Should open dialog successfully ', async function (): Promise<void> {
|
||||
const spy = sinon.spy(azdata.window, 'openDialog');
|
||||
await dialog.createDialog();
|
||||
should(spy.calledOnce).be.true();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,7 +334,7 @@ class TestComponentBase implements azdata.Component {
|
||||
}
|
||||
}
|
||||
|
||||
class TestDropdownComponent extends TestComponentBase implements azdata.DropDownComponent {
|
||||
export class TestDropdownComponent extends TestComponentBase implements azdata.DropDownComponent {
|
||||
constructor(private onClick: vscode.EventEmitter<any>) {
|
||||
super();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user