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:
Barbara Valdez
2020-07-24 19:39:03 -07:00
committed by GitHub
parent 56d1a1c1af
commit 6d9efbd603
17 changed files with 866 additions and 8 deletions

View 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);
}
}
}

View 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());
}
}
}

View 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;
}

View 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');
}
}

View File

@@ -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);
}
}

View File

@@ -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';

View File

@@ -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); }

View File

@@ -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/)

View 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
};
}
}

View 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;
}
}

View File

@@ -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) {

View File

@@ -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);

View 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);
}
});
});

View 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();
});
});

View File

@@ -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();
}