diff --git a/.vscode/launch.json b/.vscode/launch.json index d3e8009964..880b5c1ddb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -90,7 +90,7 @@ "**/winjs*.js" ], "webRoot": "${workspaceFolder}", - "timeout": 15000 + "timeout": 45000 }, { "type": "node", @@ -148,9 +148,9 @@ { "name": "Renderer and Extension Host processes", "configurations": [ - "Launch SQL Ops", + "Launch azuredatastudio", "Attach to Extension Host" ] } ] -} \ No newline at end of file +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df1ec0efa4..5912694ee1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ The more information you can provide, the more likely someone will be successful Please include the following with each issue. -* Version of SQL Ops Studio +* Version of Azure Data Studio (formerly SQL Operations Studio). > **Tip:** You can easily create an issue using `Report Issues` from Azure Data Studio Help menu. diff --git a/README.md b/README.md index 91d0c4cf5e..3f85be0f82 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Localization is now opened for 10 languages: French, Italian, German, Spanish, S ## Privacy Statement The [Microsoft Enterprise and Developer Privacy Statement](https://privacy.microsoft.com/en-us/privacystatement) describes the privacy statement of this software. -## Contributions and "thank you" +## Contributions and "Thank You" We would like to thank all our users who raised issues, and in particular the following users who helped contribute fixes: * AlexFsmn `Feature: Ability to add connection name #2332` @@ -86,8 +86,8 @@ We would like to thank all our users who raised issues, and in particular the fo * stebet for `Fix #153: Fixing sql snippets that failed on a DB with case-sensitive collation. (#152)` * SebastianPfliegel `Remove sqlExtensionHelp (#312)` * olljanat for `Implemented npm version check (#314)` -* Adam Mechanic for helping with the `whoisactive` extension -* All community localization contributors +* Adam Machanic for helping with the `whoisactive` extension +* All community localization contributors: * French: Adrien Clerbois, ANAS BELABBES, Antoine Griffard, Arian Papillon, Eric Macarez, Eric Van Thorre, Jérémy LANDON, Matthias GROSPERRIN, Maxime COQUEREL, Olivier Guinart, thierry DEMAN-BARCELÒ, Thomas Potier * Italian: Aldo Donetti, Alessandro Alpi, Andrea Dottor, Bruni Luca, Gianluca Hotz, Luca Nardi, Luigi Bruno, Marco Dal Pino, Mirco Vanini, Pasquale Ceglie, Riccardo Cappello, Sergio Govoni, Stefano Demiliani * German: Anna Henke-Gunvaldson, Ben Weissman, David Ullmer, J.M. ., Kai Modo, Konstantin Staschill, Kostja Klein, Lennart Trunk, Markus Ehrenmüller-Jensen, Mascha Kroenlein, Matthias Knoll, Mourad Louha, Thomas Hütter, Wolfgang Straßer diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 7b4d414763..1ca7809193 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -21,6 +21,7 @@ expressly granted herein, whether by implication, estoppel or otherwise. error-ex: https://github.com/Qix-/node-error-ex escape-string-regexp: https://github.com/sindresorhus/escape-string-regexp fast-plist: https://github.com/Microsoft/node-fast-plist + find-remove: https://www.npmjs.com/package/find-remove fs-extra: https://github.com/jprichardson/node-fs-extra gc-signals: https://github.com/Microsoft/node-gc-signals getmac: https://github.com/bevry/getmac diff --git a/build/gulpfile.vscode.js b/build/gulpfile.vscode.js index 51422b2a29..39199be6a7 100644 --- a/build/gulpfile.vscode.js +++ b/build/gulpfile.vscode.js @@ -76,6 +76,7 @@ const sqlBuiltInExtensions = [ 'import', 'profiler' ]; +var azureExtensions = [ 'azurecore']; const vscodeEntryPoints = _.flatten([ buildfile.entrypoint('vs/workbench/workbench.main'), @@ -276,6 +277,40 @@ function packageBuiltInExtensions() { }); } +// {{SQL CARBON EDIT}} +function packageAzureCoreTask(platform, arch) { + var destination = path.join(path.dirname(root), 'azuredatastudio') + (platform ? '-' + platform : '') + (arch ? '-' + arch : ''); + if (platform === 'darwin') { + destination = path.join(destination, 'Azure Data Studio.app', 'Contents', 'Resources', 'app', 'extensions', 'azurecore'); + } else { + destination = path.join(destination, 'resources', 'app', 'extensions', 'azurecore'); + } + + platform = platform || process.platform; + + return () => { + const root = path.resolve(path.join(__dirname, '..')); + const localExtensionDescriptions = glob.sync('extensions/*/package.json') + .map(manifestPath => { + const extensionPath = path.dirname(path.join(root, manifestPath)); + const extensionName = path.basename(extensionPath); + return { name: extensionName, path: extensionPath }; + }) + .filter(({ name }) => azureExtensions.indexOf(name) > -1); + + const localExtensions = es.merge(...localExtensionDescriptions.map(extension => { + return ext.fromLocal(extension.path); + })); + + let result = localExtensions + .pipe(util.skipDirectories()) + .pipe(util.fixWin32DirectoryPermissions()) + .pipe(filter(['**', '!LICENSE', '!LICENSES.chromium.html', '!version'])); + + return result.pipe(vfs.dest(destination)); + }; +} + function packageTask(platform, arch, opts) { opts = opts || {}; @@ -307,8 +342,10 @@ function packageTask(platform, arch, opts) { .filter(({ name }) => excludedExtensions.indexOf(name) === -1) .filter(({ name }) => builtInExtensions.every(b => b.name !== name)) // {{SQL CARBON EDIT}} - .filter(({ name }) => sqlBuiltInExtensions.indexOf(name) === -1); - packageBuiltInExtensions(); + .filter(({ name }) => sqlBuiltInExtensions.indexOf(name) === -1) + .filter(({ name }) => azureExtensions.indexOf(name) === -1); + + packageBuiltInExtensions(); const localExtensions = es.merge(...localExtensionDescriptions.map(extension => { return ext.fromLocal(extension.path) @@ -325,7 +362,6 @@ function packageTask(platform, arch, opts) { .pipe(util.cleanNodeModule('account-provider-azure', ['node_modules/date-utils/doc/**', 'node_modules/adal_node/node_modules/**'], undefined)) .pipe(util.cleanNodeModule('typescript', ['**/**'], undefined)); - const sources = es.merge(src, localExtensions, localExtensionDependencies) .pipe(util.setExecutableBit(['**/*.sh'])) .pipe(filter(['**', '!**/*.js.map'])); @@ -461,6 +497,10 @@ function packageTask(platform, arch, opts) { const buildRoot = path.dirname(root); // {{SQL CARBON EDIT}} +gulp.task('vscode-win32-x64-azurecore', ['optimize-vscode'], packageAzureCoreTask('win32', 'x64')); +gulp.task('vscode-darwin-azurecore', ['optimize-vscode'], packageAzureCoreTask('darwin')); +gulp.task('vscode-linux-x64-azurecore', ['optimize-vscode'], packageAzureCoreTask('linux', 'x64')); + gulp.task('clean-vscode-win32-ia32', util.rimraf(path.join(buildRoot, 'azuredatastudio-win32-ia32'))); gulp.task('clean-vscode-win32-x64', util.rimraf(path.join(buildRoot, 'azuredatastudio-win32-x64'))); gulp.task('clean-vscode-darwin', util.rimraf(path.join(buildRoot, 'azuredatastudio-darwin'))); @@ -469,10 +509,10 @@ gulp.task('clean-vscode-linux-x64', util.rimraf(path.join(buildRoot, 'azuredatas gulp.task('clean-vscode-linux-arm', util.rimraf(path.join(buildRoot, 'azuredatastudio-linux-arm'))); gulp.task('vscode-win32-ia32', ['optimize-vscode', 'clean-vscode-win32-ia32'], packageTask('win32', 'ia32')); -gulp.task('vscode-win32-x64', ['optimize-vscode', 'clean-vscode-win32-x64'], packageTask('win32', 'x64')); -gulp.task('vscode-darwin', ['optimize-vscode', 'clean-vscode-darwin'], packageTask('darwin')); +gulp.task('vscode-win32-x64', ['vscode-win32-x64-azurecore', 'optimize-vscode', 'clean-vscode-win32-x64'], packageTask('win32', 'x64')); +gulp.task('vscode-darwin', ['vscode-darwin-azurecore', 'optimize-vscode', 'clean-vscode-darwin'], packageTask('darwin')); gulp.task('vscode-linux-ia32', ['optimize-vscode', 'clean-vscode-linux-ia32'], packageTask('linux', 'ia32')); -gulp.task('vscode-linux-x64', ['optimize-vscode', 'clean-vscode-linux-x64'], packageTask('linux', 'x64')); +gulp.task('vscode-linux-x64', ['vscode-linux-x64-azurecore', 'optimize-vscode', 'clean-vscode-linux-x64'], packageTask('linux', 'x64')); gulp.task('vscode-linux-arm', ['optimize-vscode', 'clean-vscode-linux-arm'], packageTask('linux', 'arm')); gulp.task('vscode-win32-ia32-min', ['minify-vscode', 'clean-vscode-win32-ia32'], packageTask('win32', 'ia32', { minified: true })); diff --git a/extensions/agent/src/data/jobData.ts b/extensions/agent/src/data/jobData.ts index c5b6bff3c0..ca16a07208 100644 --- a/extensions/agent/src/data/jobData.ts +++ b/extensions/agent/src/data/jobData.ts @@ -59,6 +59,9 @@ export class JobData implements IAgentDialogData { this.category = jobInfo.category; this.description = jobInfo.description; this.enabled = jobInfo.enabled; + this.jobSteps = jobInfo.JobSteps; + this.jobSchedules = jobInfo.JobSchedules; + this.alerts = jobInfo.Alerts; } } @@ -105,8 +108,6 @@ export class JobData implements IAgentDialogData { displayName: this.JobCompletionActionCondition_Always, name: sqlops.JobCompletionActionCondition.Always.toString() }]; - - this.jobSchedules = []; } public async save() { diff --git a/extensions/agent/src/data/jobStepData.ts b/extensions/agent/src/data/jobStepData.ts index e11ec015c8..79efe4bd39 100644 --- a/extensions/agent/src/data/jobStepData.ts +++ b/extensions/agent/src/data/jobStepData.ts @@ -6,37 +6,47 @@ import { AgentUtils } from '../agentUtils'; import { IAgentDialogData, AgentDialogMode } from '../interfaces'; +import { JobData } from './jobData'; +import * as nls from 'vscode-nls'; + +const localize = nls.loadMessageBundle(); export class JobStepData implements IAgentDialogData { + + // Error Messages + private readonly CreateStepErrorMessage_JobNameIsEmpty = localize('stepData.jobNameRequired', 'Job name must be provided'); + private readonly CreateStepErrorMessage_StepNameIsEmpty = localize('stepData.stepNameRequired', 'Step name must be provided'); + public dialogMode: AgentDialogMode = AgentDialogMode.CREATE; public ownerUri: string; - public jobId: string; // + public jobId: string; public jobName: string; - public script: string; // + public script: string; public scriptName: string; - public stepName: string; // - public subSystem: string; // + public stepName: string; + public subSystem: string; public id: number; - public failureAction: string; // - public successAction: string; // + public failureAction: string; + public successAction: string; public failStepId: number; public successStepId: number; public command: string; public commandExecutionSuccessCode: number; - public databaseName: string; // + public databaseName: string; public databaseUserName: string; public server: string; - public outputFileName: string; // + public outputFileName: string; public appendToLogFile: boolean; public appendToStepHist: boolean; public writeLogToTable: boolean; public appendLogToTable: boolean; - public retryAttempts: number; // - public retryInterval: number; // + public retryAttempts: number; + public retryInterval: number; public proxyName: string; - constructor(ownerUri:string) { + constructor(ownerUri:string, jobModel?: JobData) { this.ownerUri = ownerUri; + this.jobName = jobModel.name; } public async initialize() { @@ -51,7 +61,7 @@ export class JobStepData implements IAgentDialogData { scriptName: this.scriptName, stepName: this.stepName, subSystem: this.subSystem, - id: 1, + id: this.id, failureAction: this.failureAction, successAction: this.successAction, failStepId: this.failStepId, @@ -70,7 +80,26 @@ export class JobStepData implements IAgentDialogData { retryInterval: this.retryInterval, proxyName: this.proxyName }).then(result => { - console.info(result); + if (result && result.success) { + console.info(result); + } }); } + + public validate(): { valid: boolean, errorMessages: string[] } { + let validationErrors: string[] = []; + + if (!(this.stepName && this.stepName.trim())) { + validationErrors.push(this.CreateStepErrorMessage_StepNameIsEmpty); + } + + if (!(this.jobName && this.jobName.trim())) { + validationErrors.push(this.CreateStepErrorMessage_JobNameIsEmpty); + } + + return { + valid: validationErrors.length === 0, + errorMessages: validationErrors + }; + } } \ No newline at end of file diff --git a/extensions/agent/src/dialogs/agentDialog.ts b/extensions/agent/src/dialogs/agentDialog.ts index fa2c1cea85..63d5d240d2 100644 --- a/extensions/agent/src/dialogs/agentDialog.ts +++ b/extensions/agent/src/dialogs/agentDialog.ts @@ -49,10 +49,8 @@ export abstract class AgentDialog { protected async execute() { this.updateModel(); - let success = await this.model.save(); - if (success) { - this._onSuccess.fire(this.model); - } + await this.model.save(); + this._onSuccess.fire(this.model); } protected async cancel() { diff --git a/extensions/agent/src/dialogs/jobDialog.ts b/extensions/agent/src/dialogs/jobDialog.ts index 926642ca15..8bd76509ee 100644 --- a/extensions/agent/src/dialogs/jobDialog.ts +++ b/extensions/agent/src/dialogs/jobDialog.ts @@ -10,6 +10,7 @@ import { JobStepDialog } from './jobStepDialog'; import { PickScheduleDialog } from './pickScheduleDialog'; import { AlertDialog } from './alertDialog'; import { AgentDialog } from './agentDialog'; +import { AgentUtils } from '../agentUtils'; const localize = nls.loadMessageBundle(); @@ -62,6 +63,8 @@ export class JobDialog extends AgentDialog { private readonly AlertsTopLabelString: string = localize('jobDialog.alertsList', 'Alerts list'); private readonly NewAlertButtonString: string = localize('jobDialog.newAlert', 'New Alert'); private readonly AlertNameLabelString: string = localize('jobDialog.alertNameLabel', 'Alert Name'); + private readonly AlertEnabledLabelString: string = localize('jobDialog.alertEnabledLabel', 'Enabled'); + private readonly AlertTypeLabelString: string = localize('jobDialog.alertTypeLabel', 'Type'); // UI Components private generalTab: sqlops.window.modelviewdialog.DialogTab; @@ -197,6 +200,8 @@ export class JobDialog extends AgentDialog { .withProperties({ value: 'Feature Preview' }).component(); + let steps = this.model.jobSteps ? this.model.jobSteps : []; + let data = this.convertStepsToData(steps); this.stepsTable = view.modelBuilder.table() .withProperties({ columns: [ @@ -206,8 +211,8 @@ export class JobDialog extends AgentDialog { this.StepsTable_SuccessColumnString, this.StepsTable_FailureColumnString ], - data: [], - height: 430 + data: data, + height: 750 }).component(); this.moveStepUpButton = view.modelBuilder.button() @@ -230,10 +235,14 @@ export class JobDialog extends AgentDialog { width: 80 }).component(); + let stepDialog = new JobStepDialog(this.model.ownerUri, '' , data.length + 1, this.model); + stepDialog.onSuccess((step) => { + this.model.jobSteps.push(step); + this.stepsTable.data = this.convertStepsToData(this.model.jobSteps); + }); this.newStepButton.onDidClick((e)=>{ if (this.nameTextBox.value && this.nameTextBox.value.length > 0) { - let stepDialog = new JobStepDialog(this.model.ownerUri, this.nameTextBox.value, '' , 1, this.model); - stepDialog.openNewStepDialog(); + stepDialog.openDialog(); } else { this.dialog.message = { text: this.BlankJobNameErrorText }; } @@ -250,6 +259,39 @@ export class JobDialog extends AgentDialog { }).component(); this.stepsTable.enabled = false; + this.editStepButton.enabled = false; + this.deleteStepButton.enabled = false; + + this.stepsTable.onRowSelected(() => { + // only let edit or delete steps if there's + // one step selection + if (this.stepsTable.selectedRows.length === 1) { + let rowNumber = this.stepsTable.selectedRows[0]; + let stepData = steps[rowNumber]; + this.deleteStepButton.enabled = true; + this.editStepButton.enabled = true; + this.editStepButton.onDidClick((e) => { + // implement edit steps + + // let stepDialog = new JobStepDialog(this.model.ownerUri, this.nameTextBox.value, '' , 1, this.model); + // stepDialog.openNewStepDialog(); + }); + + this.deleteStepButton.onDidClick((e) => { + AgentUtils.getAgentService().then((agentService) => { + let steps = this.model.jobSteps ? this.model.jobSteps : []; + agentService.deleteJobStep(this.ownerUri, stepData).then((result) => { + if (result && result.success) { + delete steps[rowNumber]; + this.model.jobSteps = steps; + let data = this.convertStepsToData(steps); + this.stepsTable.data = data; + } + }); + }); + }); + } + }); let formModel = view.modelBuilder.formContainer() .withFormItems([{ @@ -271,12 +313,16 @@ export class JobDialog extends AgentDialog { .withProperties({ value: 'Feature Preview' }).component(); + let alerts = this.model.alerts ? this.model.alerts : []; + let data = this.convertAlertsToData(alerts); this.alertsTable = view.modelBuilder.table() .withProperties({ columns: [ - this.AlertNameLabelString + this.AlertNameLabelString, + this.AlertEnabledLabelString, + this.AlertTypeLabelString ], - data: [], + data: data, height: 430, width: 400 }).component(); @@ -312,10 +358,12 @@ export class JobDialog extends AgentDialog { this.schedulesTable = view.modelBuilder.table() .withProperties({ columns: [ - this.ScheduleNameLabelString + PickScheduleDialog.SchedulesIDText, + PickScheduleDialog.ScheduleNameLabelText, + PickScheduleDialog.ScheduleDescription ], data: [], - height: 430, + height: 750, width: 420 }).component(); @@ -350,13 +398,11 @@ export class JobDialog extends AgentDialog { } private populateScheduleTable() { - if (this.model.jobSchedules) { - let data: any[][] = []; - for (let i = 0; i < this.model.jobSchedules.length; ++i) { - let schedule = this.model.jobSchedules[i]; - data[i] = [ schedule.name ]; - } + let schedules = this.model.jobSchedules ? this.model.jobSchedules : []; + let data = this.convertSchedulesToData(schedules); + if (data.length > 0) { this.schedulesTable.data = data; + this.schedulesTable.height = 750; } } @@ -466,6 +512,44 @@ export class JobDialog extends AgentDialog { }); } + private convertStepsToData(jobSteps: sqlops.AgentJobStepInfo[]): any[][] { + let result = []; + jobSteps.forEach(jobStep => { + let cols = []; + cols.push(jobStep.id); + cols.push(jobStep.stepName); + cols.push(jobStep.subSystem); + cols.push(jobStep.successAction); + cols.push(jobStep.failureAction); + result.push(cols); + }); + return result; + } + + private convertSchedulesToData(jobSchedules: sqlops.AgentJobScheduleInfo[]): any[][] { + let result = []; + jobSchedules.forEach(schedule => { + let cols = []; + cols.push(schedule.id); + cols.push(schedule.name); + cols.push(schedule.description); + result.push(cols); + }); + return result; + } + + private convertAlertsToData(alerts: sqlops.AgentAlertInfo[]): any[][] { + let result = []; + alerts.forEach(alert => { + let cols = []; + cols.push(alert.name); + cols.push(alert.isEnabled); + cols.push(alert.alertType.toString()); + result.push(cols); + }); + return result; + } + protected updateModel() { this.model.name = this.nameTextBox.value; this.model.owner = this.ownerTextBox.value; diff --git a/extensions/agent/src/dialogs/jobStepDialog.ts b/extensions/agent/src/dialogs/jobStepDialog.ts index a7be6423ab..737851d448 100644 --- a/extensions/agent/src/dialogs/jobStepDialog.ts +++ b/extensions/agent/src/dialogs/jobStepDialog.ts @@ -9,11 +9,12 @@ import * as vscode from 'vscode'; import { JobStepData } from '../data/jobStepData'; import { AgentUtils } from '../agentUtils'; import { JobData } from '../data/jobData'; +import { AgentDialog } from './agentDialog'; const path = require('path'); const localize = nls.loadMessageBundle(); -export class JobStepDialog { +export class JobStepDialog extends AgentDialog { // TODO: localize // Top level @@ -67,7 +68,6 @@ export class JobStepDialog { // UI Components // Dialogs - private dialog: sqlops.window.modelviewdialog.Dialog; private fileBrowserDialog: sqlops.window.modelviewdialog.Dialog; // Dialog tabs @@ -105,35 +105,27 @@ export class JobStepDialog { private fileBrowserTree: sqlops.FileBrowserTreeComponent; private jobModel: JobData; - private model: JobStepData; - private ownerUri: string; private jobName: string; private server: string; private stepId: number; constructor( ownerUri: string, - jobName: string, server: string, stepId: number, jobModel?: JobData ) { - this.model = new JobStepData(ownerUri); + super(ownerUri, new JobStepData(ownerUri, jobModel), 'New Step'); this.stepId = stepId; - this.ownerUri = ownerUri; - this.jobName = jobName; + this.jobName = jobModel.name; this.server = server; this.jobModel = jobModel; } private initializeUIComponents() { - this.dialog = sqlops.window.modelviewdialog.createDialog(this.DialogTitle); this.generalTab = sqlops.window.modelviewdialog.createTab(this.GeneralTabText); this.advancedTab = sqlops.window.modelviewdialog.createTab(this.AdvancedTabText); this.dialog.content = [this.generalTab, this.advancedTab]; - this.dialog.okButton.onClick(async () => await this.execute()); - this.dialog.okButton.label = this.OkButtonText; - this.dialog.cancelButton.label = this.CancelButtonText; } private createCommands(view, queryProvider: sqlops.QueryProvider) { @@ -478,7 +470,7 @@ export class JobStepDialog { return outputFileForm; } - protected execute() { + protected updateModel() { this.model.stepName = this.nameTextBox.value; if (!this.model.stepName || this.model.stepName.length === 0) { this.dialog.message = this.dialog.message = { text: this.BlankStepNameErrorText }; @@ -499,12 +491,20 @@ export class JobStepDialog { this.model.appendToLogFile = this.appendToExistingFileCheckbox.checked; } - public async openNewStepDialog() { + public async initializeDialog() { let databases = await AgentUtils.getDatabases(this.ownerUri); let queryProvider = await AgentUtils.getQueryProvider(); this.initializeUIComponents(); this.createGeneralTab(databases, queryProvider); this.createAdvancedTab(); - sqlops.window.modelviewdialog.openDialog(this.dialog); + this.dialog.registerCloseValidator(() => { + this.updateModel(); + let validationResult = this.model.validate(); + if (!validationResult.valid) { + // TODO: Show Error Messages + console.error(validationResult.errorMessages.join(',')); + } + return validationResult.valid; + }); } } \ No newline at end of file diff --git a/extensions/agent/src/dialogs/pickScheduleDialog.ts b/extensions/agent/src/dialogs/pickScheduleDialog.ts index d2d0a6188c..5fcd1ac1d1 100644 --- a/extensions/agent/src/dialogs/pickScheduleDialog.ts +++ b/extensions/agent/src/dialogs/pickScheduleDialog.ts @@ -18,8 +18,11 @@ export class PickScheduleDialog { private readonly DialogTitle: string = localize('pickSchedule.jobSchedules', 'Job Schedules'); private readonly OkButtonText: string = localize('pickSchedule.ok', 'OK'); private readonly CancelButtonText: string = localize('pickSchedule.cancel', 'Cancel'); - private readonly ScheduleNameLabelText: string = localize('pickSchedule.scheduleName', 'Schedule Name'); - private readonly SchedulesLabelText: string = localize('pickSchedule.schedules', 'Schedules'); + private readonly SchedulesLabelText: string = localize('pickSchedule.availableSchedules', 'Available Schedules:'); + public static readonly ScheduleNameLabelText: string = localize('pickSchedule.scheduleName', 'Name'); + public static readonly SchedulesIDText: string = localize('pickSchedule.scheduleID','ID'); + public static readonly ScheduleDescription: string = localize('pickSchedule.description','Description'); + // UI Components private dialog: sqlops.window.modelviewdialog.Dialog; @@ -50,11 +53,13 @@ export class PickScheduleDialog { this.schedulesTable = view.modelBuilder.table() .withProperties({ columns: [ - this.ScheduleNameLabelText + PickScheduleDialog.SchedulesIDText, + PickScheduleDialog.ScheduleNameLabelText, + PickScheduleDialog.ScheduleDescription ], data: [], - height: '80em', - width: '40em' + height: 750, + width: 430 }).component(); let formModel = view.modelBuilder.formContainer() @@ -69,7 +74,7 @@ export class PickScheduleDialog { let data: any[][] = []; for (let i = 0; i < this.model.schedules.length; ++i) { let schedule = this.model.schedules[i]; - data[i] = [ schedule.name ]; + data[i] = [ schedule.id, schedule.name, schedule.description ]; } this.schedulesTable.data = data; } diff --git a/extensions/agent/src/interfaces.ts b/extensions/agent/src/interfaces.ts index 1ea6f4dc05..0e86394477 100644 --- a/extensions/agent/src/interfaces.ts +++ b/extensions/agent/src/interfaces.ts @@ -13,5 +13,5 @@ export enum AgentDialogMode { export interface IAgentDialogData { dialogMode: AgentDialogMode; initialize(): void; - save(): void; + save(): Promise; } diff --git a/extensions/agent/src/mainController.ts b/extensions/agent/src/mainController.ts index e0d1eedb6a..2797e4512c 100644 --- a/extensions/agent/src/mainController.ts +++ b/extensions/agent/src/mainController.ts @@ -40,9 +40,9 @@ export class MainController { let dialog = new JobDialog(ownerUri, jobInfo); dialog.openDialog(); }); - vscode.commands.registerCommand('agent.openNewStepDialog', (ownerUri: string, jobId: string, server: string, stepId: number) => { - let dialog = new JobStepDialog(ownerUri, jobId, server, stepId); - dialog.openNewStepDialog(); + vscode.commands.registerCommand('agent.openNewStepDialog', (ownerUri: string, server: string, stepId: number) => { + let dialog = new JobStepDialog(ownerUri, server, stepId); + dialog.openDialog(); }); vscode.commands.registerCommand('agent.openPickScheduleDialog', (ownerUri: string) => { let dialog = new PickScheduleDialog(ownerUri); @@ -57,9 +57,8 @@ export class MainController { dialog.openDialog(); }); vscode.commands.registerCommand('agent.openProxyDialog', (ownerUri: string, proxyInfo: sqlops.AgentProxyInfo, credentials: sqlops.CredentialInfo[]) => { - //@TODO: reenable create proxy after snapping July release (7/14/18) - // let dialog = new ProxyDialog(ownerUri, proxyInfo, credentials); - // dialog.openDialog(); + let dialog = new ProxyDialog(ownerUri, proxyInfo, credentials); + dialog.openDialog(); MainController.showNotYetImplemented(); }); } diff --git a/extensions/azurecore/.vscodeignore b/extensions/azurecore/.vscodeignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/extensions/azurecore/README.md b/extensions/azurecore/README.md new file mode 100644 index 0000000000..003e776621 --- /dev/null +++ b/extensions/azurecore/README.md @@ -0,0 +1,21 @@ +# Azure (Core) extension for Azure Data Studio + +Welcome to the Azure (Core) extension for Azure Data Studio! This extension supports core Azure functionality such as browsing and connecting to Azure data endpoints. In the current release the following features are supported: + +* Log in to Azure and browse your accounts, subscriptions and data resources +* See Azure SQL Databases and Servers in the tree, and open these connections in Object Explorer +* Filter the list of subscriptions for a given account, to make finding specific databases easier + +## Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Privacy Statement + +The [Microsoft Enterprise and Developer Privacy Statement](https://privacy.microsoft.com/en-us/privacystatement) describes the privacy statement of this software. + +## License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the [Source EULA](https://raw.githubusercontent.com/Microsoft/azuredatastudio/master/LICENSE.txt). diff --git a/extensions/azurecore/package.json b/extensions/azurecore/package.json new file mode 100644 index 0000000000..d490feffbb --- /dev/null +++ b/extensions/azurecore/package.json @@ -0,0 +1,128 @@ +{ + "name": "azurecore", + "displayName": "%azure.displayName%", + "description": "%azure.description", + "version": "0.1.0", + "publisher": "Microsoft", + "preview": true, + "engines": { + "vscode": "^1.25.0", + "sqlops": "*" + }, + "activationEvents": [ + "onView:azureResourceExplorer" + ], + "main": "./out/extension", + "contributes": { + "configuration": [ + { + "type": "object", + "title": "%azure.config.title%", + "properties": { + "azureResource.resourceFilter": { + "type": "array", + "default": null, + "description": "%azure.resourceFilter.description%" + } + } + } + ], + "commands": [ + { + "command": "azureresource.refreshall", + "title": "%azureresource.refreshall%", + "icon": { + "dark": "resources/dark/refresh_inverse.svg", + "light": "resources/light/refresh.svg" + } + }, + { + "command": "azureresource.refresh", + "title": "%azureresource.refresh%", + "icon": { + "dark": "resources/dark/refresh_inverse.svg", + "light": "resources/light/refresh.svg" + } + }, + { + "command": "azureresource.signin", + "title": "%azureresource.signin%" + }, + { + "command": "azureresource.connectsqldb", + "title": "%azureresource.connectsqldb%", + "icon": { + "dark": "resources/dark/connect_to_inverse.svg", + "light": "resources/light/connect_to.svg" + } + }, + { + "command": "azureresource.selectsubscriptions", + "title": "%azureresource.selectsubscriptions%", + "icon": { + "dark": "resources/dark/filter_inverse.svg", + "light": "resources/light/filter.svg" + } + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "azureResource", + "title": "%azure.title%", + "icon": "resources/azure.svg" + } + ] + }, + "views": { + "azureResource": [ + { + "id": "azureResourceExplorer", + "name": "%azure.resourceExplorer.title%" + } + ] + }, + "menus": { + "view/title": [ + { + "command": "azureresource.refreshall", + "when": "view == azureResourceExplorer", + "group": "navigation@1" + } + ], + "view/item/context": [ + { + "command": "azureresource.connectsqldb", + "when": "viewItem =~ /^azureResource.itemType.database/ && viewItem != azureResource.itemType.databaseContainer && viewItem != azureResource.itemType.databaseServerContainer", + "group": "1azureresource@1" + }, + { + "command": "azureresource.connectsqldb", + "when": "viewItem =~ /^azureResource.itemType.database/ && viewItem != azureResource.itemType.databaseContainer && viewItem != azureResource.itemType.databaseServerContainer", + "group": "inline" + }, + { + "command": "azureresource.selectsubscriptions", + "when": "viewItem == azureResource.itemType.account", + "group": "inline" + }, + { + "command": "azureresource.refresh", + "when": "viewItem != azureResource.itemType.database && viewItem != azureResource.itemType.databaseServer && viewItem != azureResource.itemType.message", + "group": "inline" + } + ] + } + }, + "dependencies": { + "azure-arm-resource": "^7.0.0", + "azure-arm-sql": "^5.0.1", + "vscode-nls": "^4.0.0" + }, + "devDependencies": { + "@types/mocha": "^5.2.5", + "mocha": "^5.2.0", + "should": "^13.2.1", + "typemoq": "^2.1.0" + } +} diff --git a/extensions/azurecore/package.nls.json b/extensions/azurecore/package.nls.json new file mode 100644 index 0000000000..5b3abedb0b --- /dev/null +++ b/extensions/azurecore/package.nls.json @@ -0,0 +1,13 @@ +{ + "azure.displayName": "Azure (Core)", + "azure.description": "Browse and work with Azure resources", + "azure.config.title": "Azure Resource Configuration", + "azure.resourceFilter.description": "The resource filter, each element is an account id, a subscription id and name separated by a slash", + "azureresource.refreshall": "Refresh All", + "azureresource.refresh": "Refresh", + "azureresource.signin": "Sign In", + "azureresource.connectsqldb": "Connect", + "azureresource.selectsubscriptions": "Select Subscriptions", + "azure.title": "Azure", + "azure.resourceExplorer.title": "Resource Explorer" +} \ No newline at end of file diff --git a/extensions/azurecore/resources/azure.svg b/extensions/azurecore/resources/azure.svg new file mode 100644 index 0000000000..dd17231563 --- /dev/null +++ b/extensions/azurecore/resources/azure.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/extensions/azurecore/resources/dark/account_inverse.svg b/extensions/azurecore/resources/dark/account_inverse.svg new file mode 100644 index 0000000000..0429cb4941 --- /dev/null +++ b/extensions/azurecore/resources/dark/account_inverse.svg @@ -0,0 +1 @@ +account_inverse \ No newline at end of file diff --git a/extensions/azurecore/resources/dark/connect_to_inverse.svg b/extensions/azurecore/resources/dark/connect_to_inverse.svg new file mode 100644 index 0000000000..9d796bdc76 --- /dev/null +++ b/extensions/azurecore/resources/dark/connect_to_inverse.svg @@ -0,0 +1 @@ +connect_to_inverse \ No newline at end of file diff --git a/extensions/azurecore/resources/dark/filter_inverse.svg b/extensions/azurecore/resources/dark/filter_inverse.svg new file mode 100644 index 0000000000..60b90abd6d --- /dev/null +++ b/extensions/azurecore/resources/dark/filter_inverse.svg @@ -0,0 +1 @@ +filter_inverse_16x16 \ No newline at end of file diff --git a/extensions/azurecore/resources/dark/folder_inverse.svg b/extensions/azurecore/resources/dark/folder_inverse.svg new file mode 100644 index 0000000000..f94d427cb1 --- /dev/null +++ b/extensions/azurecore/resources/dark/folder_inverse.svg @@ -0,0 +1 @@ +folder_inverse_16x16 \ No newline at end of file diff --git a/extensions/azurecore/resources/dark/refresh_inverse.svg b/extensions/azurecore/resources/dark/refresh_inverse.svg new file mode 100644 index 0000000000..d79fdaa4e8 --- /dev/null +++ b/extensions/azurecore/resources/dark/refresh_inverse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extensions/azurecore/resources/dark/sql_database_inverse.svg b/extensions/azurecore/resources/dark/sql_database_inverse.svg new file mode 100644 index 0000000000..5eaaf7e5f0 --- /dev/null +++ b/extensions/azurecore/resources/dark/sql_database_inverse.svg @@ -0,0 +1 @@ +sql_database_inverse \ No newline at end of file diff --git a/extensions/azurecore/resources/dark/sql_server_inverse.svg b/extensions/azurecore/resources/dark/sql_server_inverse.svg new file mode 100644 index 0000000000..1ce3f48b39 --- /dev/null +++ b/extensions/azurecore/resources/dark/sql_server_inverse.svg @@ -0,0 +1 @@ +sql_server_inverse \ No newline at end of file diff --git a/extensions/azurecore/resources/dark/subscription_inverse.svg b/extensions/azurecore/resources/dark/subscription_inverse.svg new file mode 100644 index 0000000000..491fb5e895 --- /dev/null +++ b/extensions/azurecore/resources/dark/subscription_inverse.svg @@ -0,0 +1 @@ +subscription_inverse \ No newline at end of file diff --git a/extensions/azurecore/resources/light/account.svg b/extensions/azurecore/resources/light/account.svg new file mode 100644 index 0000000000..91de1865c0 --- /dev/null +++ b/extensions/azurecore/resources/light/account.svg @@ -0,0 +1 @@ +account \ No newline at end of file diff --git a/extensions/azurecore/resources/light/connect_to.svg b/extensions/azurecore/resources/light/connect_to.svg new file mode 100644 index 0000000000..1f420533d6 --- /dev/null +++ b/extensions/azurecore/resources/light/connect_to.svg @@ -0,0 +1 @@ +connect_to \ No newline at end of file diff --git a/extensions/azurecore/resources/light/filter.svg b/extensions/azurecore/resources/light/filter.svg new file mode 100644 index 0000000000..32f914f54a --- /dev/null +++ b/extensions/azurecore/resources/light/filter.svg @@ -0,0 +1 @@ +filter_16x16 \ No newline at end of file diff --git a/extensions/azurecore/resources/light/folder.svg b/extensions/azurecore/resources/light/folder.svg new file mode 100644 index 0000000000..517c9b185d --- /dev/null +++ b/extensions/azurecore/resources/light/folder.svg @@ -0,0 +1 @@ +folder_16x16 \ No newline at end of file diff --git a/extensions/azurecore/resources/light/refresh.svg b/extensions/azurecore/resources/light/refresh.svg new file mode 100644 index 0000000000..e034574819 --- /dev/null +++ b/extensions/azurecore/resources/light/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extensions/azurecore/resources/light/sql_database.svg b/extensions/azurecore/resources/light/sql_database.svg new file mode 100644 index 0000000000..e0c1584d98 --- /dev/null +++ b/extensions/azurecore/resources/light/sql_database.svg @@ -0,0 +1 @@ +sql_database \ No newline at end of file diff --git a/extensions/azurecore/resources/light/sql_server.svg b/extensions/azurecore/resources/light/sql_server.svg new file mode 100644 index 0000000000..03cff4d932 --- /dev/null +++ b/extensions/azurecore/resources/light/sql_server.svg @@ -0,0 +1 @@ +sql_server \ No newline at end of file diff --git a/extensions/azurecore/resources/light/subscription.svg b/extensions/azurecore/resources/light/subscription.svg new file mode 100644 index 0000000000..5fda717712 --- /dev/null +++ b/extensions/azurecore/resources/light/subscription.svg @@ -0,0 +1 @@ +subscription \ No newline at end of file diff --git a/extensions/azurecore/src/apiWrapper.ts b/extensions/azurecore/src/apiWrapper.ts new file mode 100644 index 0000000000..7a13231e26 --- /dev/null +++ b/extensions/azurecore/src/apiWrapper.ts @@ -0,0 +1,225 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as vscode from 'vscode'; +import * as sqlops from 'sqlops'; + +import * as constants from './constants'; + +/** + * Wrapper class to act as a facade over VSCode and Data APIs and allow us to test / mock callbacks into + * this API from our code + * + * @export + * @class ApiWrapper + */ +export class ApiWrapper { + // Data APIs + public registerConnectionProvider(provider: sqlops.ConnectionProvider): vscode.Disposable { + return sqlops.dataprotocol.registerConnectionProvider(provider); + } + + public registerObjectExplorerProvider(provider: sqlops.ObjectExplorerProvider): vscode.Disposable { + return sqlops.dataprotocol.registerObjectExplorerProvider(provider); + } + + public registerTaskServicesProvider(provider: sqlops.TaskServicesProvider): vscode.Disposable { + return sqlops.dataprotocol.registerTaskServicesProvider(provider); + } + + public registerFileBrowserProvider(provider: sqlops.FileBrowserProvider): vscode.Disposable { + return sqlops.dataprotocol.registerFileBrowserProvider(provider); + } + + public registerCapabilitiesServiceProvider(provider: sqlops.CapabilitiesProvider): vscode.Disposable { + return sqlops.dataprotocol.registerCapabilitiesServiceProvider(provider); + } + + public registerModelViewProvider(widgetId: string, handler: (modelView: sqlops.ModelView) => void): void { + return sqlops.ui.registerModelViewProvider(widgetId, handler); + } + + public registerWebviewProvider(widgetId: string, handler: (webview: sqlops.DashboardWebview) => void): void { + return sqlops.dashboard.registerWebviewProvider(widgetId, handler); + } + + public createDialog(title: string): sqlops.window.modelviewdialog.Dialog { + return sqlops.window.modelviewdialog.createDialog(title); + } + + public openDialog(dialog: sqlops.window.modelviewdialog.Dialog): void { + return sqlops.window.modelviewdialog.openDialog(dialog); + } + + public closeDialog(dialog: sqlops.window.modelviewdialog.Dialog): void { + return sqlops.window.modelviewdialog.closeDialog(dialog); + } + + public registerTaskHandler(taskId: string, handler: (profile: sqlops.IConnectionProfile) => void): void { + sqlops.tasks.registerTask(taskId, handler); + } + + public startBackgroundOperation(operationInfo: sqlops.BackgroundOperationInfo): void { + sqlops.tasks.startBackgroundOperation(operationInfo); + } + + public getActiveConnections(): Thenable { + return sqlops.connection.getActiveConnections(); + } + + public getCurrentConnection(): Thenable { + return sqlops.connection.getCurrentConnection(); + } + + public createModelViewEditor(title: string, options?: sqlops.ModelViewEditorOptions): sqlops.workspace.ModelViewEditor { + return sqlops.workspace.createModelViewEditor(title, options); + } + + // VSCode APIs + public createTerminal(name?: string, shellPath?: string, shellArgs?: string[]): vscode.Terminal { + return vscode.window.createTerminal(name, shellPath, shellArgs); + } + + public createTerminalWithOptions(options: vscode.TerminalOptions): vscode.Terminal { + return vscode.window.createTerminal(options); + } + + public executeCommand(command: string, ...rest: any[]): Thenable { + return vscode.commands.executeCommand(command, ...rest); + } + + public getFilePathRelativeToWorkspace(uri: vscode.Uri): string { + return vscode.workspace.asRelativePath(uri); + } + + public getWorkspaceFolders(): vscode.WorkspaceFolder[] { + return vscode.workspace.workspaceFolders; + } + + public getWorkspacePathFromUri(uri: vscode.Uri): string | undefined { + let workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + return workspaceFolder ? workspaceFolder.uri.fsPath : undefined; + } + + public registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any): vscode.Disposable { + return vscode.commands.registerCommand(command, callback, thisArg); + } + + public registerDocumentOpenHandler(handler: (doc: vscode.TextDocument) => any): vscode.Disposable { + return vscode.workspace.onDidOpenTextDocument(handler); + } + + public registerTreeDataProvider(viewId: string, treeDataProvider: vscode.TreeDataProvider): vscode.Disposable { + return vscode.window.registerTreeDataProvider(viewId, treeDataProvider); + } + + public setCommandContext(key: string, value: any): Thenable { + return vscode.commands.executeCommand(constants.BuiltInCommands.SetContext, key, value); + } + + /** + * Get the configuration for a extensionName + * @param extensionName The string name of the extension to get the configuration for + * @param resource The optional URI, as a URI object or a string, to use to get resource-scoped configurations + */ + public getConfiguration(extensionName?: string, resource?: vscode.Uri | string): vscode.WorkspaceConfiguration { + if (typeof resource === 'string') { + try { + resource = this.parseUri(resource); + } catch (e) { + resource = undefined; + } + } + return vscode.workspace.getConfiguration(extensionName, resource as vscode.Uri); + } + + public getExtensionConfiguration(): vscode.WorkspaceConfiguration { + return this.getConfiguration(constants.extensionConfigSectionName); + } + + /** + * Parse uri + */ + public parseUri(uri: string): vscode.Uri { + return vscode.Uri.parse(uri); + } + + public showOpenDialog(options: vscode.OpenDialogOptions): Thenable { + return vscode.window.showOpenDialog(options); + } + + public showSaveDialog(options: vscode.SaveDialogOptions): Thenable { + return vscode.window.showSaveDialog(options); + } + + public openTextDocument(uri: vscode.Uri): Thenable; + public openTextDocument(options: { language?: string; content?: string; }): Thenable; + public openTextDocument(uriOrOptions): Thenable { + return vscode.workspace.openTextDocument(uriOrOptions); + } + + public showTextDocument(document: vscode.TextDocument, column?: vscode.ViewColumn, preserveFocus?: boolean, preview?: boolean): Thenable { + let options: vscode.TextDocumentShowOptions = { + viewColumn: column, + preserveFocus: preserveFocus, + preview: preview + }; + return vscode.window.showTextDocument(document, options); + } + + public showErrorMessage(message: string, ...items: string[]): Thenable { + return vscode.window.showErrorMessage(message, ...items); + } + + public showWarningMessage(message: string, ...items: string[]): Thenable { + return vscode.window.showWarningMessage(message, ...items); + } + + public showInformationMessage(message: string, ...items: string[]): Thenable { + return vscode.window.showInformationMessage(message, ...items); + } + + public createStatusBarItem(alignment?: vscode.StatusBarAlignment, priority?: number): vscode.StatusBarItem { + return vscode.window.createStatusBarItem(alignment, priority); + } + + public get workspaceFolders(): vscode.WorkspaceFolder[] { + return vscode.workspace.workspaceFolders; + } + + public createOutputChannel(name: string): vscode.OutputChannel { + return vscode.window.createOutputChannel(name); + } + + public createWizardPage(title: string): sqlops.window.modelviewdialog.WizardPage { + return sqlops.window.modelviewdialog.createWizardPage(title); + } + + public registerCompletionItemProvider(selector: vscode.DocumentSelector, provider: vscode.CompletionItemProvider, ...triggerCharacters: string[]): vscode.Disposable { + return vscode.languages.registerCompletionItemProvider(selector, provider, ...triggerCharacters); + } + + public createTab(title: string): sqlops.window.modelviewdialog.DialogTab { + return sqlops.window.modelviewdialog.createTab(title); + } + + // Account APIs + public getAllAccounts(): Thenable { + return sqlops.accounts.getAllAccounts(); + } + + public getSecurityToken(account: sqlops.Account): Thenable<{}> { + return sqlops.accounts.getSecurityToken(account); + } + + public readonly onDidChangeAccounts = sqlops.accounts.onDidChangeAccounts; + + // Connection APIs + public openConnectionDialog(providers: string[], initialConnectionProfile?: sqlops.IConnectionProfile, connectionCompletionOptions?: sqlops.IConnectionCompletionOptions): Thenable { + return sqlops.connection.openConnectionDialog(providers, initialConnectionProfile, connectionCompletionOptions); + } +} diff --git a/extensions/azurecore/src/appContext.ts b/extensions/azurecore/src/appContext.ts new file mode 100644 index 0000000000..4abec27439 --- /dev/null +++ b/extensions/azurecore/src/appContext.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as vscode from 'vscode'; +import { ApiWrapper } from './apiWrapper'; + +/** + * Global context for the application + */ +export class AppContext { + + private serviceMap: Map = new Map(); + constructor(public readonly extensionContext: vscode.ExtensionContext, public readonly apiWrapper: ApiWrapper) { + this.apiWrapper = apiWrapper || new ApiWrapper(); + } + + public getService(serviceName: string): T { + return this.serviceMap.get(serviceName) as T; + } + + public registerService(serviceName: string, service: T): void { + this.serviceMap.set(serviceName, service); + } +} diff --git a/extensions/azurecore/src/azureResource/commands.ts b/extensions/azurecore/src/azureResource/commands.ts new file mode 100644 index 0000000000..0edb925c6f --- /dev/null +++ b/extensions/azurecore/src/azureResource/commands.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { window, QuickPickItem } from 'vscode'; +import { IConnectionProfile } from 'sqlops'; +import { generateGuid } from './utils'; +import { ApiWrapper } from '../apiWrapper'; +import { TreeNode } from '../treeNodes'; + +import { AzureResourceTreeProvider } from './tree/treeProvider'; +import { AzureResourceDatabaseServerTreeNode } from './tree/databaseServerTreeNode'; +import { AzureResourceDatabaseTreeNode } from './tree/databaseTreeNode'; +import { AzureResourceAccountTreeNode } from './tree/accountTreeNode'; +import { AzureResourceServicePool } from './servicePool'; +import { AzureResourceSubscription } from './models'; + +export function registerAzureResourceCommands(apiWrapper: ApiWrapper, tree: AzureResourceTreeProvider): void { + apiWrapper.registerCommand('azureresource.selectsubscriptions', async (node?: TreeNode) => { + if (!(node instanceof AzureResourceAccountTreeNode)) { + return; + } + + const accountNode = node as AzureResourceAccountTreeNode; + + const servicePool = AzureResourceServicePool.getInstance(); + + let subscriptions = await accountNode.getCachedSubscriptions(); + if (!subscriptions || subscriptions.length === 0) { + const credentials = await servicePool.credentialService.getCredentials(accountNode.account); + subscriptions = await servicePool.subscriptionService.getSubscriptions(accountNode.account, credentials); + } + + const selectedSubscriptions = (await servicePool.subscriptionFilterService.getSelectedSubscriptions(accountNode.account)) || []; + const selectedSubscriptionIds: string[] = []; + if (selectedSubscriptions.length > 0) { + selectedSubscriptionIds.push(...selectedSubscriptions.map((subscription) => subscription.id)); + } else { + // ALL subscriptions are selected by default + selectedSubscriptionIds.push(...subscriptions.map((subscription) => subscription.id)); + } + + interface SubscriptionQuickPickItem extends QuickPickItem { + subscription: AzureResourceSubscription; + } + + const subscriptionItems: SubscriptionQuickPickItem[] = subscriptions.map((subscription) => { + return { + label: subscription.name, + picked: selectedSubscriptionIds.indexOf(subscription.id) !== -1, + subscription: subscription + }; + }); + + const pickedSubscriptionItems = (await window.showQuickPick(subscriptionItems, { canPickMany: true })); + if (pickedSubscriptionItems && pickedSubscriptionItems.length > 0) { + tree.refresh(node, false); + + const pickedSubscriptions = pickedSubscriptionItems.map((subscriptionItem) => subscriptionItem.subscription); + await servicePool.subscriptionFilterService.saveSelectedSubscriptions(accountNode.account, pickedSubscriptions); + } + }); + + apiWrapper.registerCommand('azureresource.refreshall', () => tree.notifyNodeChanged(undefined)); + + apiWrapper.registerCommand('azureresource.refresh', async (node?: TreeNode) => { + tree.refresh(node, true); + }); + + apiWrapper.registerCommand('azureresource.connectsqldb', async (node?: TreeNode) => { + let connectionProfile: IConnectionProfile = { + id: generateGuid(), + connectionName: undefined, + serverName: undefined, + databaseName: undefined, + userName: undefined, + password: '', + authenticationType: undefined, + savePassword: true, + groupFullName: '', + groupId: '', + providerName: undefined, + saveProfile: true, + options: { + } + }; + + if (node instanceof AzureResourceDatabaseServerTreeNode) { + let databaseServer = node.databaseServer; + connectionProfile.connectionName = `connection to '${databaseServer.defaultDatabaseName}' on '${databaseServer.fullName}'`; + connectionProfile.serverName = databaseServer.fullName; + connectionProfile.databaseName = databaseServer.defaultDatabaseName; + connectionProfile.userName = databaseServer.loginName; + connectionProfile.authenticationType = 'SqlLogin'; + connectionProfile.providerName = 'MSSQL'; + } + + if (node instanceof AzureResourceDatabaseTreeNode) { + let database = node.database; + connectionProfile.connectionName = `connection to '${database.name}' on '${database.serverFullName}'`; + connectionProfile.serverName = database.serverFullName; + connectionProfile.databaseName = database.name; + connectionProfile.userName = database.loginName; + connectionProfile.authenticationType = 'SqlLogin'; + connectionProfile.providerName = 'MSSQL'; + } + + const conn = await apiWrapper.openConnectionDialog(undefined, connectionProfile, { saveConnection: true, showDashboard: true }); + if (conn) { + apiWrapper.executeCommand('workbench.view.connections'); + } + }); + + apiWrapper.registerCommand('azureresource.signin', async (node?: TreeNode) => { + apiWrapper.executeCommand('sql.action.accounts.manageLinkedAccount'); + }); +} diff --git a/extensions/azurecore/src/azureResource/constants.ts b/extensions/azurecore/src/azureResource/constants.ts new file mode 100644 index 0000000000..3fb5368927 --- /dev/null +++ b/extensions/azurecore/src/azureResource/constants.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +export enum AzureResourceItemType { + account = 'azureResource.itemType.account', + subscription = 'azureResource.itemType.subscription', + databaseContainer = 'azureResource.itemType.databaseContainer', + database = 'azureResource.itemType.database', + databaseServerContainer = 'azureResource.itemType.databaseServerContainer', + databaseServer = 'azureResource.itemType.databaseServer', + message = 'azureResource.itemType.message' +} diff --git a/extensions/azurecore/src/azureResource/errors.ts b/extensions/azurecore/src/azureResource/errors.ts new file mode 100644 index 0000000000..c0fe4a842f --- /dev/null +++ b/extensions/azurecore/src/azureResource/errors.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +export class AzureResourceCredentialError extends Error { + constructor( + message: string, + public innerError: Error + ) { + super(message); + } +} diff --git a/extensions/azurecore/src/azureResource/interfaces.ts b/extensions/azurecore/src/azureResource/interfaces.ts new file mode 100644 index 0000000000..5d21c5d408 --- /dev/null +++ b/extensions/azurecore/src/azureResource/interfaces.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { ServiceClientCredentials } from 'ms-rest'; +import { Account, DidChangeAccountsParams } from 'sqlops'; +import { Event } from 'vscode'; + +import { AzureResourceSubscription, AzureResourceDatabaseServer, AzureResourceDatabase } from './models'; + +export interface IAzureResourceAccountService { + getAccounts(): Promise; + + readonly onDidChangeAccounts: Event; +} + +export interface IAzureResourceCredentialService { + getCredentials(account: Account): Promise; +} + +export interface IAzureResourceSubscriptionService { + getSubscriptions(account: Account, credentials: ServiceClientCredentials[]): Promise; +} + +export interface IAzureResourceSubscriptionFilterService { + getSelectedSubscriptions(account: Account): Promise; + + saveSelectedSubscriptions(account: Account, selectedSubscriptions: AzureResourceSubscription[]): Promise; +} + +export interface IAzureResourceDatabaseServerService { + getDatabaseServers(subscription: AzureResourceSubscription, credentials: ServiceClientCredentials[]): Promise; +} + +export interface IAzureResourceDatabaseService { + getDatabases(subscription: AzureResourceSubscription, credentials: ServiceClientCredentials[]): Promise; +} + +export interface IAzureResourceCacheService { + get(key: string): T | undefined; + + update(key: string, value: T): void; +} + +export interface IAzureResourceContextService { + getAbsolutePath(relativePath: string): string; + + executeCommand(commandId: string, ...args: any[]): void; + + showErrorMessage(errorMessage: string): void; +} diff --git a/extensions/azurecore/src/azureResource/models.ts b/extensions/azurecore/src/azureResource/models.ts new file mode 100644 index 0000000000..21e5b4ab37 --- /dev/null +++ b/extensions/azurecore/src/azureResource/models.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +export interface AzureResourceSubscription { + id: string; + name: string; +} + +export interface AzureResourceDatabaseServer { + name: string; + fullName: string; + loginName: string; + defaultDatabaseName: string; +} + +export interface AzureResourceDatabase { + name: string; + serverName: string; + serverFullName: string; + loginName: string; +} diff --git a/extensions/azurecore/src/azureResource/servicePool.ts b/extensions/azurecore/src/azureResource/servicePool.ts new file mode 100644 index 0000000000..27aff58d98 --- /dev/null +++ b/extensions/azurecore/src/azureResource/servicePool.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { + IAzureResourceAccountService, + IAzureResourceCredentialService, + IAzureResourceSubscriptionService, + IAzureResourceSubscriptionFilterService, + IAzureResourceDatabaseService, + IAzureResourceDatabaseServerService, + IAzureResourceCacheService, + IAzureResourceContextService } from './interfaces'; + +export class AzureResourceServicePool { + private constructor() { } + + public static getInstance(): AzureResourceServicePool { + return AzureResourceServicePool._instance; + } + + public contextService: IAzureResourceContextService; + public cacheService: IAzureResourceCacheService; + public accountService: IAzureResourceAccountService; + public credentialService: IAzureResourceCredentialService; + public subscriptionService: IAzureResourceSubscriptionService; + public subscriptionFilterService: IAzureResourceSubscriptionFilterService; + public databaseService: IAzureResourceDatabaseService; + public databaseServerService: IAzureResourceDatabaseServerService; + + private static readonly _instance = new AzureResourceServicePool(); +} diff --git a/extensions/azurecore/src/azureResource/services/accountService.ts b/extensions/azurecore/src/azureResource/services/accountService.ts new file mode 100644 index 0000000000..bc38f823a5 --- /dev/null +++ b/extensions/azurecore/src/azureResource/services/accountService.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { Event } from 'vscode'; +import { Account, DidChangeAccountsParams } from 'sqlops'; +import { ApiWrapper } from '../../apiWrapper'; + +import { IAzureResourceAccountService } from '../interfaces'; + +export class AzureResourceAccountService implements IAzureResourceAccountService { + public constructor( + apiWrapper: ApiWrapper + ) { + this._apiWrapper = apiWrapper; + this._onDidChangeAccounts = this._apiWrapper.onDidChangeAccounts; + } + + public async getAccounts(): Promise { + return await this._apiWrapper.getAllAccounts(); + } + + public get onDidChangeAccounts(): Event { + return this._onDidChangeAccounts; + } + + private _apiWrapper: ApiWrapper = undefined; + private _onDidChangeAccounts: Event = undefined; +} diff --git a/extensions/azurecore/src/azureResource/services/cacheService.ts b/extensions/azurecore/src/azureResource/services/cacheService.ts new file mode 100644 index 0000000000..ba0d100273 --- /dev/null +++ b/extensions/azurecore/src/azureResource/services/cacheService.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { ExtensionContext } from "vscode"; + +import { IAzureResourceCacheService } from "../interfaces"; + +export class AzureResourceCacheService implements IAzureResourceCacheService { + public constructor( + public readonly context: ExtensionContext + ) { + } + + public get(key: string): T | undefined { + return this.context.workspaceState.get(key); + } + + public update(key: string, value: T): void { + this.context.workspaceState.update(key, value); + } +} \ No newline at end of file diff --git a/extensions/azurecore/src/azureResource/services/contextService.ts b/extensions/azurecore/src/azureResource/services/contextService.ts new file mode 100644 index 0000000000..0e153d48e5 --- /dev/null +++ b/extensions/azurecore/src/azureResource/services/contextService.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { ExtensionContext } from "vscode"; +import { ApiWrapper } from "../../apiWrapper"; + +import { IAzureResourceContextService } from "../interfaces"; + +export class AzureResourceContextService implements IAzureResourceContextService { + public constructor( + context: ExtensionContext, + apiWrapper: ApiWrapper + ) { + this._context = context; + this._apiWrapper = apiWrapper; + } + + public getAbsolutePath(relativePath: string): string { + return this._context.asAbsolutePath(relativePath); + } + + public executeCommand(commandId: string, ...args: any[]): void { + this._apiWrapper.executeCommand(commandId, args); + } + + public showErrorMessage(errorMessage: string): void { + this._apiWrapper.showErrorMessage(errorMessage); + } + + private _context: ExtensionContext = undefined; + private _apiWrapper: ApiWrapper = undefined; +} diff --git a/extensions/azurecore/src/azureResource/services/credentialService.ts b/extensions/azurecore/src/azureResource/services/credentialService.ts new file mode 100644 index 0000000000..028181fb87 --- /dev/null +++ b/extensions/azurecore/src/azureResource/services/credentialService.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { Account } from 'sqlops'; +import { TokenCredentials, ServiceClientCredentials } from 'ms-rest'; +import { ApiWrapper } from '../../apiWrapper'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { IAzureResourceCredentialService } from '../interfaces'; +import { AzureResourceCredentialError } from '../errors'; + +export class AzureResourceCredentialService implements IAzureResourceCredentialService { + public constructor( + apiWrapper: ApiWrapper + ) { + this._apiWrapper = apiWrapper; + } + + public async getCredentials(account: Account): Promise { + try { + let credentials: TokenCredentials[] = []; + let tokens = await this._apiWrapper.getSecurityToken(account); + + for (let tenant of account.properties.tenants) { + let token = tokens[tenant.id].token; + let tokenType = tokens[tenant.id].tokenType; + + credentials.push(new TokenCredentials(token, tokenType)); + } + + return credentials; + } catch (error) { + throw new AzureResourceCredentialError(localize('azureResource.services.credentialService.credentialError', 'Failed to get credential for account {0}. Please refresh the account.', account.key.accountId), error); + } + } + + private _apiWrapper: ApiWrapper = undefined; +} diff --git a/extensions/azurecore/src/azureResource/services/databaseServerService.ts b/extensions/azurecore/src/azureResource/services/databaseServerService.ts new file mode 100644 index 0000000000..9145629a34 --- /dev/null +++ b/extensions/azurecore/src/azureResource/services/databaseServerService.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { ServiceClientCredentials } from 'ms-rest'; +import { SqlManagementClient } from 'azure-arm-sql'; + +import { IAzureResourceDatabaseServerService } from '../interfaces'; +import { AzureResourceSubscription, AzureResourceDatabaseServer } from '../models'; + +export class AzureResourceDatabaseServerService implements IAzureResourceDatabaseServerService { + public async getDatabaseServers(subscription: AzureResourceSubscription, credentials: ServiceClientCredentials[]): Promise { + let databaseServers: AzureResourceDatabaseServer[] = []; + for (let cred of credentials) { + let sqlManagementClient = new SqlManagementClient(cred, subscription.id); + try { + let svrs = await sqlManagementClient.servers.list(); + svrs.forEach((svr) => databaseServers.push({ + name: svr.name, + fullName: svr.fullyQualifiedDomainName, + loginName: svr.administratorLogin, + defaultDatabaseName: 'master' + })); + } catch (error) { + if (error.code === 'InvalidAuthenticationTokenTenant' && error.statusCode === 401) { + /** + * There may be multiple tenants for an account and it may throw exceptions like following. Just swallow the exception here. + * The access token is from the wrong issuer. It must match one of the tenants associated with this subscription. + */ + } + } + } + + return databaseServers; + } +} diff --git a/extensions/azurecore/src/azureResource/services/databaseService.ts b/extensions/azurecore/src/azureResource/services/databaseService.ts new file mode 100644 index 0000000000..643db5c1e6 --- /dev/null +++ b/extensions/azurecore/src/azureResource/services/databaseService.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { ServiceClientCredentials } from 'ms-rest'; +import { SqlManagementClient } from 'azure-arm-sql'; + +import { IAzureResourceDatabaseService } from '../interfaces'; +import { AzureResourceSubscription, AzureResourceDatabase } from '../models'; + +export class AzureResourceDatabaseService implements IAzureResourceDatabaseService { + public async getDatabases(subscription: AzureResourceSubscription, credentials: ServiceClientCredentials[]): Promise { + let databases: AzureResourceDatabase[] = []; + for (let cred of credentials) { + let sqlManagementClient = new SqlManagementClient(cred, subscription.id); + try { + let svrs = await sqlManagementClient.servers.list(); + for (let svr of svrs) { + // Extract resource group name from svr.id + let svrIdRegExp = new RegExp(`\/subscriptions\/${subscription.id}\/resourceGroups\/(.+)\/providers\/Microsoft\.Sql\/servers\/${svr.name}`); + if (!svrIdRegExp.test(svr.id)) { + continue; + } + + let founds = svrIdRegExp.exec(svr.id); + let resouceGroup = founds[1]; + + let dbs = await sqlManagementClient.databases.listByServer(resouceGroup, svr.name); + dbs.forEach((db) => databases.push({ + name: db.name, + serverName: svr.name, + serverFullName: svr.fullyQualifiedDomainName, + loginName: svr.administratorLogin + })); + } + } catch (error) { + if (error.code === 'InvalidAuthenticationTokenTenant' && error.statusCode === 401) { + /** + * There may be multiple tenants for an account and it may throw exceptions like following. Just swallow the exception here. + * The access token is from the wrong issuer. It must match one of the tenants associated with this subscription. + */ + } + } + } + + return databases; + } +} diff --git a/extensions/azurecore/src/azureResource/services/subscriptionFilterService.ts b/extensions/azurecore/src/azureResource/services/subscriptionFilterService.ts new file mode 100644 index 0000000000..9e6bea1b08 --- /dev/null +++ b/extensions/azurecore/src/azureResource/services/subscriptionFilterService.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { WorkspaceConfiguration, ConfigurationTarget } from 'vscode'; +import { Account } from 'sqlops'; + +import { IAzureResourceSubscriptionFilterService, IAzureResourceCacheService } from '../interfaces'; +import { AzureResourceSubscription } from '../models'; + +interface AzureResourceSelectedSubscriptionsCache { + selectedSubscriptions: { [accountId: string]: AzureResourceSubscription[]}; +} + +export class AzureResourceSubscriptionFilterService implements IAzureResourceSubscriptionFilterService { + public constructor( + cacheService: IAzureResourceCacheService + ) { + this._cacheService = cacheService; + } + + public async getSelectedSubscriptions(account: Account): Promise { + let selectedSubscriptions: AzureResourceSubscription[] = []; + + const cache = this._cacheService.get(AzureResourceSubscriptionFilterService.CacheKey); + if (cache) { + selectedSubscriptions = cache.selectedSubscriptions[account.key.accountId]; + } + + return selectedSubscriptions; + } + + public async saveSelectedSubscriptions(account: Account, selectedSubscriptions: AzureResourceSubscription[]): Promise { + let selectedSubscriptionsCache: { [accountId: string]: AzureResourceSubscription[]} = {}; + + const cache = this._cacheService.get(AzureResourceSubscriptionFilterService.CacheKey); + if (cache) { + selectedSubscriptionsCache = cache.selectedSubscriptions; + } + + if (!selectedSubscriptionsCache) { + selectedSubscriptionsCache = {}; + } + + selectedSubscriptionsCache[account.key.accountId] = selectedSubscriptions; + + this._cacheService.update(AzureResourceSubscriptionFilterService.CacheKey, { selectedSubscriptions: selectedSubscriptionsCache }); + + const filters: string[] = []; + for (const accountId in selectedSubscriptionsCache) { + filters.push(...selectedSubscriptionsCache[accountId].map((subcription) => `${accountId}/${subcription.id}/${subcription.name}`)); + } + + const resourceFilterConfig = this._config.inspect(AzureResourceSubscriptionFilterService.FilterConfigName); + let configTarget = ConfigurationTarget.Global; + if (resourceFilterConfig) { + if (resourceFilterConfig.workspaceFolderValue) { + configTarget = ConfigurationTarget.WorkspaceFolder; + } else if (resourceFilterConfig.workspaceValue) { + configTarget = ConfigurationTarget.Workspace; + } else if (resourceFilterConfig.globalValue) { + configTarget = ConfigurationTarget.Global; + } + } + + await this._config.update(AzureResourceSubscriptionFilterService.FilterConfigName, filters, configTarget); + } + + private _config: WorkspaceConfiguration = undefined; + private _cacheService: IAzureResourceCacheService = undefined; + + private static readonly FilterConfigName = 'resourceFilter'; + private static readonly CacheKey = 'azureResource.cache.selectedSubscriptions'; +} diff --git a/extensions/azurecore/src/azureResource/services/subscriptionService.ts b/extensions/azurecore/src/azureResource/services/subscriptionService.ts new file mode 100644 index 0000000000..9a8cfe0779 --- /dev/null +++ b/extensions/azurecore/src/azureResource/services/subscriptionService.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { Account } from 'sqlops'; +import { ServiceClientCredentials } from 'ms-rest'; +import { SubscriptionClient } from 'azure-arm-resource'; + +import { IAzureResourceSubscriptionService } from '../interfaces'; +import { AzureResourceSubscription } from '../models'; + +export class AzureResourceSubscriptionService implements IAzureResourceSubscriptionService { + public async getSubscriptions(account: Account, credentials: ServiceClientCredentials[]): Promise { + let subscriptions: AzureResourceSubscription[] = []; + for (let cred of credentials) { + let subClient = new SubscriptionClient.SubscriptionClient(cred); + try { + let subs = await subClient.subscriptions.list(); + subs.forEach((sub) => subscriptions.push({ + id: sub.subscriptionId, + name: sub.displayName + })); + } catch (error) { + // Swallow the exception here. + } + } + + return subscriptions; + } +} diff --git a/extensions/azurecore/src/azureResource/tree/accountNotSignedInTreeNode.ts b/extensions/azurecore/src/azureResource/tree/accountNotSignedInTreeNode.ts new file mode 100644 index 0000000000..47008428aa --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/accountNotSignedInTreeNode.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { NodeInfo } from 'sqlops'; +import { TreeNode } from '../../treeNodes'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { AzureResourceItemType } from '../constants'; + +export class AzureResourceAccountNotSignedInTreeNode extends TreeNode { + public getChildren(): TreeNode[] | Promise { + return []; + } + + public getTreeItem(): TreeItem | Promise { + let item = new TreeItem(AzureResourceAccountNotSignedInTreeNode.SignInLabel, TreeItemCollapsibleState.None); + item.contextValue = AzureResourceItemType.message; + item.command = { + title: AzureResourceAccountNotSignedInTreeNode.SignInLabel, + command: 'azureresource.signin', + arguments: [this] + }; + return item; + } + + public getNodeInfo(): NodeInfo { + return { + label: AzureResourceAccountNotSignedInTreeNode.SignInLabel, + isLeaf: true, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: AzureResourceItemType.message, + nodeSubType: undefined, + iconType: AzureResourceItemType.message + }; + } + + public get nodePathValue(): string { + return 'message_accountNotSignedIn'; + } + + private static readonly SignInLabel = localize('azureResource.tree.accountNotSignedInTreeNode.signIn', 'Sign in to Azure ...'); +} diff --git a/extensions/azurecore/src/azureResource/tree/accountTreeNode.ts b/extensions/azurecore/src/azureResource/tree/accountTreeNode.ts new file mode 100644 index 0000000000..a95a485ece --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/accountTreeNode.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { Account, NodeInfo } from 'sqlops'; +import { TreeNode } from '../../treeNodes'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { AzureResourceContainerTreeNodeBase } from './baseTreeNodes'; +import { AzureResourceItemType } from '../constants'; +import { AzureResourceSubscriptionTreeNode } from './subscriptionTreeNode'; +import { AzureResourceMessageTreeNode } from './messageTreeNode'; +import { AzureResourceErrorMessageUtil } from '../utils'; +import { AzureResourceSubscription } from '../models'; +import { IAzureResourceTreeChangeHandler } from './treeProvider'; + +export class AzureResourceAccountTreeNode extends AzureResourceContainerTreeNodeBase { + public constructor( + account: Account, + treeChangeHandler: IAzureResourceTreeChangeHandler + ) { + super(account, treeChangeHandler, undefined); + + this._id = `account_${this.account.key.accountId}`; + this._label = this.generateLabel(); + } + + public async getChildren(): Promise { + try { + let subscriptions: AzureResourceSubscription[] = []; + + if (this._isClearingCache) { + const credentials = await this.getCredentials(); + subscriptions = (await this.servicePool.subscriptionService.getSubscriptions(this.account, credentials)) || []; + + let cache = this.getCache(); + if (!cache) { + cache = { subscriptions: { } }; + } + cache.subscriptions[this.account.key.accountId] = subscriptions; + this.updateCache(cache); + + this._isClearingCache = false; + } else { + subscriptions = await this.getCachedSubscriptions(); + } + + this._totalSubscriptionCount = subscriptions.length; + + let selectedSubscriptions = await this.servicePool.subscriptionFilterService.getSelectedSubscriptions(this.account); + let selectedSubscriptionIds = (selectedSubscriptions || []).map((subscription) => subscription.id); + if (selectedSubscriptionIds.length > 0) { + subscriptions = subscriptions.filter((subscription) => selectedSubscriptionIds.indexOf(subscription.id) !== -1); + this._selectedSubscriptionCount = selectedSubscriptionIds.length; + } else { + // ALL subscriptions are listed by default + this._selectedSubscriptionCount = this._totalSubscriptionCount; + } + + this.refreshLabel(); + + if (subscriptions.length === 0) { + return [AzureResourceMessageTreeNode.create(AzureResourceAccountTreeNode.NoSubscriptions, this)]; + } else { + return subscriptions.map((subscription) => new AzureResourceSubscriptionTreeNode(subscription, this.account, this.treeChangeHandler, this)); + } + } catch (error) { + return [AzureResourceMessageTreeNode.create(AzureResourceErrorMessageUtil.getErrorMessage(error), this)]; + } + } + + public async getCachedSubscriptions(): Promise { + const subscriptions: AzureResourceSubscription[] = []; + const cache = this.getCache(); + if (cache) { + subscriptions.push(...cache.subscriptions[this.account.key.accountId]); + } + return subscriptions; + } + + public getTreeItem(): TreeItem | Promise { + let item = new TreeItem(this._label, TreeItemCollapsibleState.Collapsed); + item.id = this._id; + item.contextValue = AzureResourceItemType.account; + item.iconPath = { + dark: this.servicePool.contextService.getAbsolutePath('resources/dark/account_inverse.svg'), + light: this.servicePool.contextService.getAbsolutePath('resources/light/account.svg') + }; + return item; + } + + public getNodeInfo(): NodeInfo { + return { + label: this._label, + isLeaf: false, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: AzureResourceItemType.account, + nodeSubType: undefined, + iconType: AzureResourceItemType.account + }; + } + + public get nodePathValue(): string { + return this._id; + } + + public get totalSubscriptionCount(): number { + return this._totalSubscriptionCount; + } + + public get selectedSubscriptionCount(): number { + return this._selectedSubscriptionCount; + } + + protected refreshLabel(): void { + const newLabel = this.generateLabel(); + if (this._label !== newLabel) { + this._label = newLabel; + this.treeChangeHandler.notifyNodeChanged(this); + } + } + + protected get cacheKey(): string { + return 'azureResource.cache.subscriptions'; + } + + private generateLabel(): string { + let label = `${this.account.displayInfo.displayName} (${this.account.key.accountId})`; + + if (this._totalSubscriptionCount !== 0) { + label += ` (${this._selectedSubscriptionCount} / ${this._totalSubscriptionCount} subscriptions)`; + } + + return label; + } + + private _id: string = undefined; + private _label: string = undefined; + private _totalSubscriptionCount = 0; + private _selectedSubscriptionCount = 0; + + private static readonly NoSubscriptions = localize('azureResource.tree.accountTreeNode.noSubscriptions', 'No Subscriptions found.'); +} + +interface AzureResourceSubscriptionsCache { + subscriptions: { [accountId: string]: AzureResourceSubscription[] }; +} diff --git a/extensions/azurecore/src/azureResource/tree/baseTreeNodes.ts b/extensions/azurecore/src/azureResource/tree/baseTreeNodes.ts new file mode 100644 index 0000000000..ecea876ec1 --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/baseTreeNodes.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { Account } from 'sqlops'; +import { ServiceClientCredentials } from 'ms-rest'; +import { TreeNode } from '../../treeNodes'; + +import { AzureResourceServicePool } from '../servicePool'; +import { AzureResourceCredentialError } from '../errors'; +import { IAzureResourceTreeChangeHandler } from './treeChangeHandler'; + +export abstract class AzureResourceTreeNodeBase extends TreeNode { + public constructor( + public readonly treeChangeHandler: IAzureResourceTreeChangeHandler, + parent: TreeNode + ) { + super(); + + this.parent = parent; + } + + public readonly servicePool = AzureResourceServicePool.getInstance(); +} + +export abstract class AzureResourceContainerTreeNodeBase extends AzureResourceTreeNodeBase { + public constructor( + public readonly account: Account, + treeChangeHandler: IAzureResourceTreeChangeHandler, + parent: TreeNode + ) { + super(treeChangeHandler, parent); + } + + public clearCache(): void { + this._isClearingCache = true; + } + + public get isClearingCache(): boolean { + return this._isClearingCache; + } + + protected async getCredentials(): Promise { + try { + return await this.servicePool.credentialService.getCredentials(this.account); + } catch (error) { + if (error instanceof AzureResourceCredentialError) { + this.servicePool.contextService.showErrorMessage(error.message); + + this.servicePool.contextService.executeCommand('azureresource.signin'); + } else { + throw error; + } + } + } + + protected updateCache(cache: T): void { + this.servicePool.cacheService.update(this.cacheKey, cache); + } + + protected getCache(): T { + return this.servicePool.cacheService.get(this.cacheKey); + } + + protected abstract get cacheKey(): string; + + protected _isClearingCache = true; +} diff --git a/extensions/azurecore/src/azureResource/tree/databaseContainerTreeNode.ts b/extensions/azurecore/src/azureResource/tree/databaseContainerTreeNode.ts new file mode 100644 index 0000000000..26d5f6d70c --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/databaseContainerTreeNode.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { Account, NodeInfo } from 'sqlops'; +import { TreeNode } from '../../treeNodes'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { AzureResourceContainerTreeNodeBase } from './baseTreeNodes'; +import { AzureResourceItemType } from '../constants'; +import { AzureResourceErrorMessageUtil } from '../utils'; +import { AzureResourceDatabaseTreeNode } from './databaseTreeNode'; +import { AzureResourceMessageTreeNode } from './messageTreeNode'; +import { AzureResourceSubscription, AzureResourceDatabase } from '../models'; +import { IAzureResourceTreeChangeHandler } from './treeProvider'; + +export class AzureResourceDatabaseContainerTreeNode extends AzureResourceContainerTreeNodeBase { + public constructor( + public readonly subscription: AzureResourceSubscription, + account: Account, + treeChangeHandler: IAzureResourceTreeChangeHandler, + parent: TreeNode + ) { + super(account, treeChangeHandler, parent); + } + + public async getChildren(): Promise { + try { + let databases: AzureResourceDatabase[] = []; + + if (this._isClearingCache) { + let credentials = await this.getCredentials(); + databases = (await this.servicePool.databaseService.getDatabases(this.subscription, credentials)) || []; + + let cache = this.getCache(); + if (!cache) { + cache = { databases: { } }; + } + cache.databases[this.subscription.id] = databases; + this.updateCache(cache); + + this._isClearingCache = false; + } else { + const cache = this.getCache(); + if (cache) { + databases = cache.databases[this.subscription.id] || []; + } + } + + if (databases.length === 0) { + return [AzureResourceMessageTreeNode.create(AzureResourceDatabaseContainerTreeNode.NoDatabases, this)]; + } else { + return databases.map((database) => new AzureResourceDatabaseTreeNode(database, this.treeChangeHandler, this)); + } + } catch (error) { + return [AzureResourceMessageTreeNode.create(AzureResourceErrorMessageUtil.getErrorMessage(error), this)]; + } + } + + public getTreeItem(): TreeItem | Promise { + let item = new TreeItem(AzureResourceDatabaseContainerTreeNode.Label, TreeItemCollapsibleState.Collapsed); + item.contextValue = AzureResourceItemType.databaseContainer; + item.iconPath = { + dark: this.servicePool.contextService.getAbsolutePath('resources/dark/folder_inverse.svg'), + light: this.servicePool.contextService.getAbsolutePath('resources/light/folder.svg') + }; + return item; + } + + public getNodeInfo(): NodeInfo { + return { + label: AzureResourceDatabaseContainerTreeNode.Label, + isLeaf: false, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: AzureResourceItemType.databaseContainer, + nodeSubType: undefined, + iconType: AzureResourceItemType.databaseContainer + }; + } + + public get nodePathValue(): string { + return 'databaseContainer'; + } + + protected get cacheKey(): string { + return 'azureResource.cache.databases'; + } + + private static readonly Label = localize('azureResource.tree.databaseContainerTreeNode.label', 'SQL Databases'); + private static readonly NoDatabases = localize('azureResource.tree.databaseContainerTreeNode.noDatabases', 'No SQL Databases found.'); +} + +interface AzureResourceDatabasesCache { + databases: { [subscriptionId: string]: AzureResourceDatabase[] }; +} diff --git a/extensions/azurecore/src/azureResource/tree/databaseServerContainerTreeNode.ts b/extensions/azurecore/src/azureResource/tree/databaseServerContainerTreeNode.ts new file mode 100644 index 0000000000..6f2921e092 --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/databaseServerContainerTreeNode.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { Account, NodeInfo } from 'sqlops'; +import { TreeNode } from '../../treeNodes'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { AzureResourceContainerTreeNodeBase } from './baseTreeNodes'; +import { AzureResourceItemType } from '../constants'; +import { AzureResourceMessageTreeNode } from './messageTreeNode'; +import { AzureResourceErrorMessageUtil } from '../utils'; +import { AzureResourceSubscription, AzureResourceDatabaseServer } from '../models'; +import { AzureResourceDatabaseServerTreeNode } from './databaseServerTreeNode'; +import { IAzureResourceTreeChangeHandler } from './treeProvider'; + +export class AzureResourceDatabaseServerContainerTreeNode extends AzureResourceContainerTreeNodeBase { + public constructor( + public readonly subscription: AzureResourceSubscription, + account: Account, + treeChangeHandler: IAzureResourceTreeChangeHandler, + parent: TreeNode + ) { + super(account, treeChangeHandler, parent); + } + + public async getChildren(): Promise { + try { + let databaseServers: AzureResourceDatabaseServer[] = []; + + if (this._isClearingCache) { + let credentials = await this.getCredentials(); + databaseServers = (await this.servicePool.databaseServerService.getDatabaseServers(this.subscription, credentials)) || []; + + let cache = this.getCache(); + if (!cache) { + cache = { databaseServers: { } }; + } + cache.databaseServers[this.subscription.id] = databaseServers; + this.updateCache(cache); + + this._isClearingCache = false; + } else { + const cache = this.getCache(); + if (cache) { + databaseServers = cache.databaseServers[this.subscription.id] || []; + } + } + + if (databaseServers.length === 0) { + return [AzureResourceMessageTreeNode.create(AzureResourceDatabaseServerContainerTreeNode.NoDatabaseServers, this)]; + } else { + return databaseServers.map((server) => new AzureResourceDatabaseServerTreeNode(server, this.treeChangeHandler, this)); + } + } catch (error) { + return [AzureResourceMessageTreeNode.create(AzureResourceErrorMessageUtil.getErrorMessage(error), this)]; + } + } + + public getTreeItem(): TreeItem | Promise { + let item = new TreeItem(AzureResourceDatabaseServerContainerTreeNode.Label, TreeItemCollapsibleState.Collapsed); + item.contextValue = AzureResourceItemType.databaseServerContainer; + item.iconPath = { + dark: this.servicePool.contextService.getAbsolutePath('resources/dark/folder_inverse.svg'), + light: this.servicePool.contextService.getAbsolutePath('resources/light/folder.svg') + }; + return item; + } + + public getNodeInfo(): NodeInfo { + return { + label: AzureResourceDatabaseServerContainerTreeNode.Label, + isLeaf: false, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: AzureResourceItemType.databaseServerContainer, + nodeSubType: undefined, + iconType: AzureResourceItemType.databaseServerContainer + }; + } + + public get nodePathValue(): string { + return 'databaseServerContainer'; + } + + protected get cacheKey(): string { + return 'azureResource.cache.databaseServers'; + } + + private static readonly Label = localize('azureResource.tree.databaseServerContainerTreeNode.label', 'SQL Servers'); + private static readonly NoDatabaseServers = localize('azureResource.tree.databaseContainerTreeNode.noDatabaseServers', 'No SQL Servers found.'); +} + +interface AzureResourceDatabaseServersCache { + databaseServers: { [subscriptionId: string]: AzureResourceDatabaseServer[] }; +} diff --git a/extensions/azurecore/src/azureResource/tree/databaseServerTreeNode.ts b/extensions/azurecore/src/azureResource/tree/databaseServerTreeNode.ts new file mode 100644 index 0000000000..2c8cf5bd4b --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/databaseServerTreeNode.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { NodeInfo } from 'sqlops'; +import { TreeNode } from '../../treeNodes'; + +import { AzureResourceTreeNodeBase } from './baseTreeNodes'; +import { AzureResourceItemType } from '../constants'; +import { AzureResourceDatabaseServer } from '../models'; +import { IAzureResourceTreeChangeHandler } from './treeProvider'; + +export class AzureResourceDatabaseServerTreeNode extends AzureResourceTreeNodeBase { + public constructor( + public readonly databaseServer: AzureResourceDatabaseServer, + treeChangeHandler: IAzureResourceTreeChangeHandler, + parent: TreeNode + ) { + super(treeChangeHandler, parent); + } + + public async getChildren(): Promise { + return []; + } + + public getTreeItem(): TreeItem | Promise { + let item = new TreeItem(this.databaseServer.name, TreeItemCollapsibleState.None); + item.contextValue = AzureResourceItemType.databaseServer; + item.iconPath = { + dark: this.servicePool.contextService.getAbsolutePath('resources/dark/sql_server_inverse.svg'), + light: this.servicePool.contextService.getAbsolutePath('resources/light/sql_server.svg') + }; + return item; + } + + public getNodeInfo(): NodeInfo { + return { + label: this.databaseServer.name, + isLeaf: true, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: AzureResourceItemType.databaseServer, + nodeSubType: undefined, + iconType: AzureResourceItemType.databaseServer + }; + } + + public get nodePathValue(): string { + return `databaseServer_${this.databaseServer.name}`; + } +} diff --git a/extensions/azurecore/src/azureResource/tree/databaseTreeNode.ts b/extensions/azurecore/src/azureResource/tree/databaseTreeNode.ts new file mode 100644 index 0000000000..1089a6e0c5 --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/databaseTreeNode.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { NodeInfo } from 'sqlops'; +import { TreeNode } from '../../treeNodes'; + +import { AzureResourceTreeNodeBase } from './baseTreeNodes'; +import { AzureResourceItemType } from '../constants'; +import { AzureResourceDatabase } from '../models'; +import { IAzureResourceTreeChangeHandler } from './treeProvider'; + +export class AzureResourceDatabaseTreeNode extends AzureResourceTreeNodeBase { + public constructor( + public readonly database: AzureResourceDatabase, + treeChangeHandler: IAzureResourceTreeChangeHandler, + parent: TreeNode + ) { + super(treeChangeHandler, parent); + + this._label = `${this.database.name} (${this.database.serverName})`; + } + + public async getChildren(): Promise { + return []; + } + + public getTreeItem(): TreeItem | Promise { + let item = new TreeItem(this._label, TreeItemCollapsibleState.None); + item.contextValue = AzureResourceItemType.database; + item.iconPath = { + dark: this.servicePool.contextService.getAbsolutePath('resources/dark/sql_database_inverse.svg'), + light: this.servicePool.contextService.getAbsolutePath('resources/light/sql_database.svg') + }; + return item; + } + + public getNodeInfo(): NodeInfo { + return { + label: this._label, + isLeaf: true, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: AzureResourceItemType.database, + nodeSubType: undefined, + iconType: AzureResourceItemType.database + }; + } + + public get nodePathValue(): string { + return `database_${this.database.name}`; + } + + private _label: string = undefined; +} diff --git a/extensions/azurecore/src/azureResource/tree/messageTreeNode.ts b/extensions/azurecore/src/azureResource/tree/messageTreeNode.ts new file mode 100644 index 0000000000..edea2e5a76 --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/messageTreeNode.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { NodeInfo } from 'sqlops'; +import { TreeNode } from '../../treeNodes'; + +import { AzureResourceItemType } from '../constants'; + +export class AzureResourceMessageTreeNode extends TreeNode { + public constructor( + public readonly message: string, + parent: TreeNode + ) { + super(); + + this.parent = parent; + this._id = `message_${AzureResourceMessageTreeNode._messageNum++}`; + } + + public static create(message: string, parent: TreeNode): AzureResourceMessageTreeNode { + return new AzureResourceMessageTreeNode(message, parent); + } + + public getChildren(): TreeNode[] | Promise { + return []; + } + + public getTreeItem(): TreeItem | Promise { + let item = new TreeItem(this.message, TreeItemCollapsibleState.None); + item.contextValue = AzureResourceItemType.message; + return item; + } + + public getNodeInfo(): NodeInfo { + return { + label: this.message, + isLeaf: true, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: AzureResourceItemType.message, + nodeSubType: undefined, + iconType: AzureResourceItemType.message + }; + } + + public get nodePathValue(): string { + return this._id; + } + + private _id: string; + + private static _messageNum: number = 0; +} diff --git a/extensions/azurecore/src/azureResource/tree/subscriptionTreeNode.ts b/extensions/azurecore/src/azureResource/tree/subscriptionTreeNode.ts new file mode 100644 index 0000000000..ff8474ca3f --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/subscriptionTreeNode.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { Account, NodeInfo } from 'sqlops'; +import { TreeNode } from '../../treeNodes'; + +import { AzureResourceTreeNodeBase, AzureResourceContainerTreeNodeBase } from './baseTreeNodes'; +import { AzureResourceItemType } from '../constants'; +import { AzureResourceDatabaseContainerTreeNode } from './databaseContainerTreeNode'; +import { AzureResourceDatabaseServerContainerTreeNode } from './databaseServerContainerTreeNode'; +import { AzureResourceSubscription } from '../models'; +import { IAzureResourceTreeChangeHandler } from './treeChangeHandler'; + +export class AzureResourceSubscriptionTreeNode extends AzureResourceTreeNodeBase { + public constructor( + public readonly subscription: AzureResourceSubscription, + account: Account, + treeChangeHandler: IAzureResourceTreeChangeHandler, + parent: TreeNode + ) { + super(treeChangeHandler, parent); + + this._children.push(new AzureResourceDatabaseContainerTreeNode(subscription, account, treeChangeHandler, this)); + this._children.push(new AzureResourceDatabaseServerContainerTreeNode(subscription, account, treeChangeHandler, this)); + } + + public async getChildren(): Promise { + return this._children; + } + + public getTreeItem(): TreeItem | Promise { + let item = new TreeItem(this.subscription.name, TreeItemCollapsibleState.Collapsed); + item.contextValue = AzureResourceItemType.subscription; + item.iconPath = { + dark: this.servicePool.contextService.getAbsolutePath('resources/dark/subscription_inverse.svg'), + light: this.servicePool.contextService.getAbsolutePath('resources/light/subscription.svg') + }; + return item; + } + + public getNodeInfo(): NodeInfo { + return { + label: this.subscription.name, + isLeaf: false, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: AzureResourceItemType.subscription, + nodeSubType: undefined, + iconType: AzureResourceItemType.subscription + }; + } + + public get nodePathValue(): string { + return `subscription_${this.subscription.id}`; + } + + private _children: AzureResourceContainerTreeNodeBase[] = []; +} diff --git a/extensions/azurecore/src/azureResource/tree/treeChangeHandler.ts b/extensions/azurecore/src/azureResource/tree/treeChangeHandler.ts new file mode 100644 index 0000000000..b7db2ed6b4 --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/treeChangeHandler.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TreeNode } from '../../treeNodes'; + +export interface IAzureResourceTreeChangeHandler { + notifyNodeChanged(node: TreeNode): void; +} diff --git a/extensions/azurecore/src/azureResource/tree/treeProvider.ts b/extensions/azurecore/src/azureResource/tree/treeProvider.ts new file mode 100644 index 0000000000..7cfd4ce89f --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/treeProvider.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TreeDataProvider, EventEmitter, Event, TreeItem } from 'vscode'; +import { DidChangeAccountsParams } from 'sqlops'; +import { TreeNode } from '../../treeNodes'; +import { setInterval, clearInterval } from 'timers'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { AzureResourceServicePool } from '../servicePool'; +import { AzureResourceAccountTreeNode } from './accountTreeNode'; +import { AzureResourceAccountNotSignedInTreeNode } from './accountNotSignedInTreeNode'; +import { AzureResourceMessageTreeNode } from './messageTreeNode'; +import { AzureResourceContainerTreeNodeBase, AzureResourceTreeNodeBase } from './baseTreeNodes'; +import { AzureResourceErrorMessageUtil } from '../utils'; + +export interface IAzureResourceTreeChangeHandler { + notifyNodeChanged(node: TreeNode): void; +} + +export class AzureResourceTreeProvider implements TreeDataProvider, IAzureResourceTreeChangeHandler { + public constructor() { + AzureResourceServicePool.getInstance().accountService.onDidChangeAccounts((e: DidChangeAccountsParams) => { this._onDidChangeTreeData.fire(undefined); }); + } + + public async getChildren(element?: TreeNode): Promise { + if (element) { + return element.getChildren(true); + } + + if (!this.isSystemInitialized) { + this._loadingTimer = setInterval(async () => { + try { + // Call sqlops.accounts.getAllAccounts() to determine whether the system has been initialized. + await AzureResourceServicePool.getInstance().accountService.getAccounts(); + + // System has been initialized + this.isSystemInitialized = true; + + if (this._loadingTimer) { + clearInterval(this._loadingTimer); + } + + this._onDidChangeTreeData.fire(undefined); + } catch (error) { + // System not initialized yet + this.isSystemInitialized = false; + } + }, AzureResourceTreeProvider.LoadingTimerInterval); + + return [AzureResourceMessageTreeNode.create(AzureResourceTreeProvider.Loading, undefined)]; + } + + try { + const accounts = await AzureResourceServicePool.getInstance().accountService.getAccounts(); + + if (accounts && accounts.length > 0) { + return accounts.map((account) => new AzureResourceAccountTreeNode(account, this)); + } else { + return [new AzureResourceAccountNotSignedInTreeNode()]; + } + } catch (error) { + return [AzureResourceMessageTreeNode.create(AzureResourceErrorMessageUtil.getErrorMessage(error), undefined)]; + } + } + + public get onDidChangeTreeData(): Event { + return this._onDidChangeTreeData.event; + } + + public notifyNodeChanged(node: TreeNode): void { + this._onDidChangeTreeData.fire(node); + } + + public async refresh(node: TreeNode, isClearingCache: boolean): Promise { + if (isClearingCache) { + if ((node instanceof AzureResourceContainerTreeNodeBase)) { + node.clearCache(); + } + } + + this._onDidChangeTreeData.fire(node); + } + + public getTreeItem(element: TreeNode): TreeItem | Thenable { + return element.getTreeItem(); + } + + public isSystemInitialized: boolean = false; + + private _loadingTimer: NodeJS.Timer = undefined; + private _onDidChangeTreeData = new EventEmitter(); + + private static readonly Loading = localize('azureResource.tree.treeProvider.loading', 'Loading ...'); + private static readonly LoadingTimerInterval = 5000; +} diff --git a/extensions/azurecore/src/azureResource/utils.ts b/extensions/azurecore/src/azureResource/utils.ts new file mode 100644 index 0000000000..99f177eafb --- /dev/null +++ b/extensions/azurecore/src/azureResource/utils.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +export function getErrorMessage(error: Error | string): string { + return (error instanceof Error) ? error.message : error; +} + + +export class AzureResourceErrorMessageUtil { + public static getErrorMessage(error: Error | string): string { + return localize('azureResource.error', 'Error: {0}', getErrorMessage(error)); + } +} + +export function generateGuid(): string { + let hexValues: string[] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; + // c.f. rfc4122 (UUID version 4 = xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) + let oct: string = ''; + let tmp: number; + /* tslint:disable:no-bitwise */ + 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]; + } + + // 'Set the two most significant bits (bits 6 and 7) of the clock_seq_hi_and_reserved to zero and one, respectively' + 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); + /* tslint:enable:no-bitwise */ +} \ No newline at end of file diff --git a/extensions/azurecore/src/constants.ts b/extensions/azurecore/src/constants.ts new file mode 100644 index 0000000000..994cfc1f6a --- /dev/null +++ b/extensions/azurecore/src/constants.ts @@ -0,0 +1,8 @@ +'use strict'; + +export const extensionConfigSectionName = 'azure'; +export const ViewType = 'view'; + +export enum BuiltInCommands { + SetContext = 'setContext' +} diff --git a/extensions/azurecore/src/controllers/controllerBase.ts b/extensions/azurecore/src/controllers/controllerBase.ts new file mode 100644 index 0000000000..15e31972b4 --- /dev/null +++ b/extensions/azurecore/src/controllers/controllerBase.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as vscode from 'vscode'; + +import { AppContext } from '../appContext'; +import { ApiWrapper } from '../apiWrapper'; + +export default abstract class ControllerBase implements vscode.Disposable { + + public constructor(protected appContext: AppContext) { + } + + protected get apiWrapper(): ApiWrapper { + return this.appContext.apiWrapper; + } + + public get extensionContext(): vscode.ExtensionContext { + return this.appContext && this.appContext.extensionContext; + } + + abstract activate(): Promise; + + abstract deactivate(): void; + + public dispose(): void { + this.deactivate(); + } +} + diff --git a/extensions/azurecore/src/controllers/mainController.ts b/extensions/azurecore/src/controllers/mainController.ts new file mode 100644 index 0000000000..7f38fd8667 --- /dev/null +++ b/extensions/azurecore/src/controllers/mainController.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import ControllerBase from './controllerBase'; + +import { AzureResourceTreeProvider } from '../azureResource/tree/treeProvider'; +import { registerAzureResourceCommands } from '../azureResource/commands'; +import { AzureResourceServicePool } from '../azureResource/servicePool'; +import { AzureResourceCredentialService } from '../azureResource/services/credentialService'; +import { AzureResourceAccountService } from '../azureResource/services/accountService'; +import { AzureResourceSubscriptionService } from '../azureResource/services/subscriptionService'; +import { AzureResourceSubscriptionFilterService } from '../azureResource/services/subscriptionFilterService'; +import { AzureResourceDatabaseServerService } from '../azureResource/services/databaseServerService'; +import { AzureResourceDatabaseService } from '../azureResource/services/databaseService'; +import { AzureResourceCacheService } from '../azureResource/services/cacheService'; +import { AzureResourceContextService } from '../azureResource/services/contextService'; + +/** + * The main controller class that initializes the extension + */ +export default class MainController extends ControllerBase { + // PUBLIC METHODS ////////////////////////////////////////////////////// + /** + * Deactivates the extension + */ + public deactivate(): void { + } + + public activate(): Promise { + this.configureAzureResource(); + return Promise.resolve(true); + } + + private configureAzureResource(): void { + let servicePool = AzureResourceServicePool.getInstance(); + servicePool.cacheService = new AzureResourceCacheService(this.extensionContext); + servicePool.contextService = new AzureResourceContextService(this.extensionContext, this.apiWrapper); + servicePool.accountService = new AzureResourceAccountService(this.apiWrapper); + servicePool.credentialService = new AzureResourceCredentialService(this.apiWrapper); + servicePool.subscriptionService = new AzureResourceSubscriptionService(); + servicePool.subscriptionFilterService = new AzureResourceSubscriptionFilterService(new AzureResourceCacheService(this.extensionContext)); + servicePool.databaseService = new AzureResourceDatabaseService(); + servicePool.databaseServerService = new AzureResourceDatabaseServerService(); + + let azureResourceTree = new AzureResourceTreeProvider(); + this.extensionContext.subscriptions.push(this.apiWrapper.registerTreeDataProvider('azureResourceExplorer', azureResourceTree)); + + registerAzureResourceCommands(this.apiWrapper, azureResourceTree); + } +} diff --git a/extensions/azurecore/src/extension.ts b/extensions/azurecore/src/extension.ts new file mode 100644 index 0000000000..9d693b3fec --- /dev/null +++ b/extensions/azurecore/src/extension.ts @@ -0,0 +1,40 @@ +'use strict'; + +import * as vscode from 'vscode'; + +import MainController from './controllers/mainController'; +import { AppContext } from './appContext'; +import ControllerBase from './controllers/controllerBase'; +import { ApiWrapper } from './apiWrapper'; + +let controllers: ControllerBase[] = []; + +// this method is called when your extension is activated +// your extension is activated the very first time the command is executed +export function activate(extensionContext: vscode.ExtensionContext) { + let appContext = new AppContext(extensionContext, new ApiWrapper()); + let activations: Promise[] = []; + + // Start the main controller + let mainController = new MainController(appContext); + controllers.push(mainController); + extensionContext.subscriptions.push(mainController); + activations.push(mainController.activate()); + + return Promise.all(activations) + .then((results: boolean[]) => { + for (let result of results) { + if (!result) { + return false; + } + } + return true; + }); +} + +// this method is called when your extension is deactivated +export function deactivate() { + for (let controller of controllers) { + controller.deactivate(); + } +} diff --git a/extensions/azurecore/src/test/azureResource/tree/accountNotSignedInTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/tree/accountNotSignedInTreeNode.test.ts new file mode 100644 index 0000000000..9a3cb3f866 --- /dev/null +++ b/extensions/azurecore/src/test/azureResource/tree/accountNotSignedInTreeNode.test.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import * as vscode from 'vscode'; +import 'mocha'; + +import { AzureResourceItemType } from '../../../azureResource/constants'; +import { AzureResourceAccountNotSignedInTreeNode } from '../../../azureResource/tree/accountNotSignedInTreeNode'; + +describe('AzureResourceAccountNotSignedInTreeNode.info', function(): void { + it('Should be correct.', async function(): Promise { + const label = 'Sign in to Azure ...'; + + const treeNode = new AzureResourceAccountNotSignedInTreeNode(); + + should(treeNode.nodePathValue).equal('message_accountNotSignedIn'); + + const treeItem = await treeNode.getTreeItem(); + should(treeItem.label).equal(label); + should(treeItem.contextValue).equal(AzureResourceItemType.message); + should(treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.None); + should(treeItem.command).not.undefined(); + should(treeItem.command.title).equal(label); + should(treeItem.command.command).equal('azureresource.signin'); + + const nodeInfo = treeNode.getNodeInfo(); + should(nodeInfo.isLeaf).true(); + should(nodeInfo.label).equal(label); + should(nodeInfo.nodeType).equal(AzureResourceItemType.message); + should(nodeInfo.iconType).equal(AzureResourceItemType.message); + }); +}); diff --git a/extensions/azurecore/src/test/azureResource/tree/accountTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/tree/accountTreeNode.test.ts new file mode 100644 index 0000000000..27b64e0160 --- /dev/null +++ b/extensions/azurecore/src/test/azureResource/tree/accountTreeNode.test.ts @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import * as TypeMoq from 'typemoq'; +import * as sqlops from 'sqlops'; +import * as vscode from 'vscode'; +import 'mocha'; +import { ServiceClientCredentials } from 'ms-rest'; + +import { AzureResourceServicePool } from '../../../azureResource/servicePool'; +import { + IAzureResourceCacheService, + IAzureResourceContextService, + IAzureResourceCredentialService, + IAzureResourceSubscriptionService, + IAzureResourceSubscriptionFilterService +} from '../../../azureResource/interfaces'; +import { IAzureResourceTreeChangeHandler } from '../../../azureResource/tree/treeChangeHandler'; +import { AzureResourceAccountTreeNode } from '../../../azureResource/tree/accountTreeNode'; +import { AzureResourceSubscription } from '../../../azureResource/models'; +import { AzureResourceSubscriptionTreeNode } from '../../../azureResource/tree/subscriptionTreeNode'; +import { AzureResourceItemType } from '../../../azureResource/constants'; +import { AzureResourceMessageTreeNode } from '../../../azureResource/tree/messageTreeNode'; + +// Mock services +const mockServicePool = AzureResourceServicePool.getInstance(); + +let mockCacheService: TypeMoq.IMock; +let mockContextService: TypeMoq.IMock; +let mockCredentialService: TypeMoq.IMock; +let mockSubscriptionService: TypeMoq.IMock; +let mockSubscriptionFilterService: TypeMoq.IMock; + +let mockTreeChangeHandler: TypeMoq.IMock; + +// Mock test data +const mockAccount: sqlops.Account = { + key: { + accountId: 'mock_account', + providerId: 'mock_provider' + }, + displayInfo: { + displayName: 'mock_account@test.com', + accountType: 'Microsoft', + contextualDisplayName: 'test' + }, + properties: undefined, + isStale: false +}; + +const mockCredential = TypeMoq.Mock.ofType().object; +const mockCredentials = [mockCredential]; + +const mockSubscription1: AzureResourceSubscription = { + id: 'mock_subscription_1', + name: 'mock subscription 1' +}; +const mockSubscription2: AzureResourceSubscription = { + id: 'mock_subscription_2', + name: 'mock subscription 2' +}; +const mockSubscriptions = [mockSubscription1, mockSubscription2]; +const mockFilteredSubscriptions = [mockSubscription1]; + +let mockSubscriptionCache: { subscriptions: { [accountId: string]: AzureResourceSubscription[]} }; + +describe('AzureResourceAccountTreeNode.info', function(): void { + beforeEach(() => { + mockContextService = TypeMoq.Mock.ofType(); + mockCacheService = TypeMoq.Mock.ofType(); + mockCredentialService = TypeMoq.Mock.ofType(); + mockSubscriptionService = TypeMoq.Mock.ofType(); + mockSubscriptionFilterService = TypeMoq.Mock.ofType(); + + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + + mockSubscriptionCache = { subscriptions: {} }; + + mockServicePool.contextService = mockContextService.object; + mockServicePool.cacheService = mockCacheService.object; + mockServicePool.credentialService = mockCredentialService.object; + mockServicePool.subscriptionService = mockSubscriptionService.object; + mockServicePool.subscriptionFilterService = mockSubscriptionFilterService.object; + + mockCredentialService.setup((o) => o.getCredentials(mockAccount)).returns(() => Promise.resolve(mockCredentials)); + mockCacheService.setup((o) => o.get(TypeMoq.It.isAnyString())).returns(() => mockSubscriptionCache); + mockCacheService.setup((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny())).returns(() => mockSubscriptionCache.subscriptions[mockAccount.key.accountId] = mockSubscriptions); + }); + + it('Should be correct when created.', async function(): Promise { + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockTreeChangeHandler.object); + + const accountTreeNodeId = `account_${mockAccount.key.accountId}`; + const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockAccount.key.accountId})`; + + should(accountTreeNode.nodePathValue).equal(accountTreeNodeId); + + const treeItem = await accountTreeNode.getTreeItem(); + should(treeItem.id).equal(accountTreeNodeId); + should(treeItem.label).equal(accountTreeNodeLabel); + should(treeItem.contextValue).equal(AzureResourceItemType.account); + should(treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.Collapsed); + + const nodeInfo = accountTreeNode.getNodeInfo(); + should(nodeInfo.label).equal(accountTreeNodeLabel); + should(nodeInfo.isLeaf).false(); + should(nodeInfo.nodeType).equal(AzureResourceItemType.account); + should(nodeInfo.iconType).equal(AzureResourceItemType.account); + }); + + it('Should be correct when there are subscriptions listed.', async function(): Promise { + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, mockCredentials)).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve(undefined)); + + const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockAccount.key.accountId}) (${mockSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; + + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockTreeChangeHandler.object); + + await accountTreeNode.getChildren(); + + const treeItem = await accountTreeNode.getTreeItem(); + should(treeItem.label).equal(accountTreeNodeLabel); + + const nodeInfo = accountTreeNode.getNodeInfo(); + should(nodeInfo.label).equal(accountTreeNodeLabel); + }); + + it('Should be correct when there are subscriptions filtered.', async function(): Promise { + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, mockCredentials)).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve(mockFilteredSubscriptions)); + + const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockAccount.key.accountId}) (${mockFilteredSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; + + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockTreeChangeHandler.object); + + await accountTreeNode.getChildren(); + + const treeItem = await accountTreeNode.getTreeItem(); + should(treeItem.label).equal(accountTreeNodeLabel); + + const nodeInfo = accountTreeNode.getNodeInfo(); + should(nodeInfo.label).equal(accountTreeNodeLabel); + }); +}); + +describe('AzureResourceAccountTreeNode.getChildren', function(): void { + beforeEach(() => { + mockCacheService = TypeMoq.Mock.ofType(); + mockCredentialService = TypeMoq.Mock.ofType(); + mockSubscriptionService = TypeMoq.Mock.ofType(); + mockSubscriptionFilterService = TypeMoq.Mock.ofType(); + + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + + mockSubscriptionCache = { subscriptions: {} }; + + mockServicePool.cacheService = mockCacheService.object; + mockServicePool.credentialService = mockCredentialService.object; + mockServicePool.subscriptionService = mockSubscriptionService.object; + mockServicePool.subscriptionFilterService = mockSubscriptionFilterService.object; + + mockCredentialService.setup((o) => o.getCredentials(mockAccount)).returns(() => Promise.resolve(mockCredentials)); + mockCacheService.setup((o) => o.get(TypeMoq.It.isAnyString())).returns(() => mockSubscriptionCache); + mockCacheService.setup((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny())).returns(() => mockSubscriptionCache.subscriptions[mockAccount.key.accountId] = mockSubscriptions); + }); + + it('Should load subscriptions from scratch and update cache when it is clearing cache.', async function(): Promise { + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, mockCredentials)).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve(undefined)); + + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockTreeChangeHandler.object); + + const children = await accountTreeNode.getChildren(); + + mockCredentialService.verify((o) => o.getCredentials(mockAccount), TypeMoq.Times.once()); + mockSubscriptionService.verify((o) => o.getSubscriptions(mockAccount, mockCredentials), TypeMoq.Times.once()); + mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.once()); + mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.once()); + mockSubscriptionFilterService.verify((o) => o.getSelectedSubscriptions(mockAccount), TypeMoq.Times.once()); + + mockTreeChangeHandler.verify((o) => o.notifyNodeChanged(accountTreeNode), TypeMoq.Times.once()); + + should(accountTreeNode.totalSubscriptionCount).equal(mockSubscriptions.length); + should(accountTreeNode.selectedSubscriptionCount).equal(mockSubscriptions.length); + should(accountTreeNode.isClearingCache).false(); + + should(children).Array(); + should(children.length).equal(mockSubscriptions.length); + + should(Object.keys(mockSubscriptionCache.subscriptions)).deepEqual([mockAccount.key.accountId]); + should(mockSubscriptionCache.subscriptions[mockAccount.key.accountId]).deepEqual(mockSubscriptions); + + for (let ix = 0; ix < mockSubscriptions.length; ix++) { + const child = children[ix]; + const subscription = mockSubscriptions[ix]; + + should(child).instanceof(AzureResourceSubscriptionTreeNode); + should(child.nodePathValue).equal(`subscription_${subscription.id}`); + } + }); + + it('Should load subscriptions from cache when it is not clearing cache.', async function(): Promise { + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, mockCredentials)).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve(undefined)); + + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockTreeChangeHandler.object); + + await accountTreeNode.getChildren(); + const children = await accountTreeNode.getChildren(); + + mockCredentialService.verify((o) => o.getCredentials(mockAccount), TypeMoq.Times.exactly(1)); + mockSubscriptionService.verify((o) => o.getSubscriptions(mockAccount, mockCredentials), TypeMoq.Times.exactly(1)); + mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.exactly(2)); + mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.exactly(1)); + + should(children.length).equal(mockSubscriptionCache.subscriptions[mockAccount.key.accountId].length); + + for (let ix = 0; ix < mockSubscriptionCache.subscriptions[mockAccount.key.accountId].length; ix++) { + should(children[ix].nodePathValue).equal(`subscription_${mockSubscriptionCache.subscriptions[mockAccount.key.accountId][ix].id}`); + } + }); + + it('Should handle when there is no subscriptions.', async function(): Promise { + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, mockCredentials)).returns(() => Promise.resolve(undefined)); + + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockTreeChangeHandler.object); + + const children = await accountTreeNode.getChildren(); + + should(accountTreeNode.totalSubscriptionCount).equal(0); + + should(children).Array(); + should(children.length).equal(1); + should(children[0]).instanceof(AzureResourceMessageTreeNode); + should(children[0].nodePathValue).startWith('message_'); + should(children[0].getNodeInfo().label).equal('No Subscriptions found.'); + }); + + it('Should honor subscription filtering.', async function(): Promise { + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, mockCredentials)).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve(mockFilteredSubscriptions)); + + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockTreeChangeHandler.object); + + const children = await accountTreeNode.getChildren(); + + mockSubscriptionFilterService.verify((o) => o.getSelectedSubscriptions(mockAccount), TypeMoq.Times.once()); + + should(accountTreeNode.selectedSubscriptionCount).equal(mockFilteredSubscriptions.length); + should(children.length).equal(mockFilteredSubscriptions.length); + + for (let ix = 0; ix < mockFilteredSubscriptions.length; ix++) { + should(children[ix].nodePathValue).equal(`subscription_${mockFilteredSubscriptions[ix].id}`); + } + }); + + it('Should handle errors.', async function(): Promise { + const mockError = 'Test error'; + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, mockCredentials)).returns(() => { throw new Error(mockError); }); + + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockTreeChangeHandler.object); + + const children = await accountTreeNode.getChildren(); + + mockCredentialService.verify((o) => o.getCredentials(mockAccount), TypeMoq.Times.once()); + mockSubscriptionService.verify((o) => o.getSubscriptions(mockAccount, mockCredentials), TypeMoq.Times.once()); + mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.never()); + mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.never()); + mockSubscriptionFilterService.verify((o) => o.getSelectedSubscriptions(mockAccount), TypeMoq.Times.never()); + + should(children).Array(); + should(children.length).equal(1); + should(children[0]).instanceof(AzureResourceMessageTreeNode); + should(children[0].nodePathValue).startWith('message_'); + should(children[0].getNodeInfo().label).equal(`Error: ${mockError}`); + }); +}); + +describe('AzureResourceAccountTreeNode.clearCache', function() : void { + beforeEach(() => { + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + }); + + it('Should clear cache.', async function(): Promise { + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockTreeChangeHandler.object); + accountTreeNode.clearCache(); + should(accountTreeNode.isClearingCache).true(); + }); +}); diff --git a/extensions/azurecore/src/test/azureResource/tree/databaseContainerTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/tree/databaseContainerTreeNode.test.ts new file mode 100644 index 0000000000..a2e8640265 --- /dev/null +++ b/extensions/azurecore/src/test/azureResource/tree/databaseContainerTreeNode.test.ts @@ -0,0 +1,219 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import * as TypeMoq from 'typemoq'; +import * as sqlops from 'sqlops'; +import * as vscode from 'vscode'; +import 'mocha'; +import { ServiceClientCredentials } from 'ms-rest'; + +import { AzureResourceServicePool } from '../../../azureResource/servicePool'; +import { + IAzureResourceCacheService, + IAzureResourceContextService, + IAzureResourceCredentialService, + IAzureResourceDatabaseService +} from '../../../azureResource/interfaces'; +import { IAzureResourceTreeChangeHandler } from '../../../azureResource/tree/treeChangeHandler'; +import { AzureResourceSubscription, AzureResourceDatabase } from '../../../azureResource/models'; +import { AzureResourceItemType } from '../../../azureResource/constants'; +import { AzureResourceMessageTreeNode } from '../../../azureResource/tree/messageTreeNode'; +import { AzureResourceDatabaseContainerTreeNode } from '../../../azureResource/tree/databaseContainerTreeNode'; +import { AzureResourceDatabaseTreeNode } from '../../../azureResource/tree/databaseTreeNode'; + +// Mock services +const mockServicePool = AzureResourceServicePool.getInstance(); + +let mockCacheService: TypeMoq.IMock; +let mockContextService: TypeMoq.IMock; +let mockCredentialService: TypeMoq.IMock; +let mockDatabaseService: TypeMoq.IMock; + +let mockTreeChangeHandler: TypeMoq.IMock; + +// Mock test data +const mockAccount: sqlops.Account = { + key: { + accountId: 'mock_account', + providerId: 'mock_provider' + }, + displayInfo: { + displayName: 'mock_account@test.com', + accountType: 'Microsoft', + contextualDisplayName: 'test' + }, + properties: undefined, + isStale: false +}; + +const mockCredential = TypeMoq.Mock.ofType().object; +const mockCredentials = [mockCredential]; + +const mockSubscription: AzureResourceSubscription = { + id: 'mock_subscription', + name: 'mock subscription' +}; + +const mockDatabase1: AzureResourceDatabase = { + name: 'mock database 1', + serverName: 'mock server 1', + serverFullName: 'mock server 1', + loginName: 'mock user 1' +}; +const mockDatabase2: AzureResourceDatabase = { + name: 'mock database 2', + serverName: 'mock server 2', + serverFullName: 'mock server 2', + loginName: 'mock user 2' +}; +const mockDatabases = [mockDatabase1, mockDatabase2]; + +let mockDatabaseContainerCache: { databases: { [subscriptionId: string]: AzureResourceDatabase[] } }; + +describe('AzureResourceDatabaseContainerTreeNode.info', function(): void { + beforeEach(() => { + mockContextService = TypeMoq.Mock.ofType(); + + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + + mockServicePool.contextService = mockContextService.object; + }); + + it('Should be correct when created.', async function(): Promise { + const databaseContainerTreeNode = new AzureResourceDatabaseContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + + const databaseContainerTreeNodeLabel = 'SQL Databases'; + + should(databaseContainerTreeNode.nodePathValue).equal('databaseContainer'); + + const treeItem = await databaseContainerTreeNode.getTreeItem(); + should(treeItem.label).equal(databaseContainerTreeNodeLabel); + should(treeItem.contextValue).equal(AzureResourceItemType.databaseContainer); + should(treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.Collapsed); + + const nodeInfo = databaseContainerTreeNode.getNodeInfo(); + should(nodeInfo.isLeaf).false(); + should(nodeInfo.label).equal(databaseContainerTreeNodeLabel); + should(nodeInfo.nodeType).equal(AzureResourceItemType.databaseContainer); + should(nodeInfo.iconType).equal(AzureResourceItemType.databaseContainer); + }); +}); + +describe('AzureResourceDatabaseContainerTreeNode.getChildren', function(): void { + beforeEach(() => { + mockCacheService = TypeMoq.Mock.ofType(); + mockCredentialService = TypeMoq.Mock.ofType(); + mockDatabaseService = TypeMoq.Mock.ofType(); + + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + + mockDatabaseContainerCache = { databases: {} }; + + mockServicePool.cacheService = mockCacheService.object; + mockServicePool.credentialService = mockCredentialService.object; + mockServicePool.databaseService = mockDatabaseService.object; + + mockCredentialService.setup((o) => o.getCredentials(mockAccount)).returns(() => Promise.resolve(mockCredentials)); + mockCacheService.setup((o) => o.get(TypeMoq.It.isAnyString())).returns(() => mockDatabaseContainerCache); + mockCacheService.setup((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny())).returns(() => mockDatabaseContainerCache.databases[mockSubscription.id] = mockDatabases); + }); + + it('Should load databases from scratch and update cache when it is clearing cache.', async function(): Promise { + mockDatabaseService.setup((o) => o.getDatabases(mockSubscription, mockCredentials)).returns(() => Promise.resolve(mockDatabases)); + + const databaseContainerTreeNode = new AzureResourceDatabaseContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + + const children = await databaseContainerTreeNode.getChildren(); + + mockCredentialService.verify((o) => o.getCredentials(mockAccount), TypeMoq.Times.once()); + mockDatabaseService.verify((o) => o.getDatabases(mockSubscription, mockCredentials), TypeMoq.Times.once()); + mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.once()); + mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.once()); + + should(databaseContainerTreeNode.isClearingCache).false(); + + should(children).Array(); + should(children.length).equal(mockDatabases.length); + + should(Object.keys(mockDatabaseContainerCache.databases)).deepEqual([mockSubscription.id]); + should(mockDatabaseContainerCache.databases[mockSubscription.id]).deepEqual(mockDatabases); + + for (let ix = 0; ix < mockDatabases.length; ix++) { + const child = children[ix]; + const database = mockDatabases[ix]; + + should(child).instanceof(AzureResourceDatabaseTreeNode); + should(child.nodePathValue).equal(`database_${database.name}`); + } + }); + + it('Should load databases from cache when it is not clearing cache.', async function(): Promise { + mockDatabaseService.setup((o) => o.getDatabases(mockSubscription, mockCredentials)).returns(() => Promise.resolve(mockDatabases)); + + const databaseContainerTreeNode = new AzureResourceDatabaseContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + + await databaseContainerTreeNode.getChildren(); + const children = await databaseContainerTreeNode.getChildren(); + + mockCredentialService.verify((o) => o.getCredentials(mockAccount), TypeMoq.Times.exactly(1)); + mockDatabaseService.verify((o) => o.getDatabases(mockSubscription, mockCredentials), TypeMoq.Times.exactly(1)); + mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.exactly(2)); + mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.exactly(1)); + + should(children.length).equal(mockDatabaseContainerCache.databases[mockSubscription.id].length); + + for (let ix = 0; ix < mockDatabaseContainerCache.databases[mockSubscription.id].length; ix++) { + should(children[ix].nodePathValue).equal(`database_${mockDatabaseContainerCache.databases[mockSubscription.id][ix].name}`); + } + }); + + it('Should handle when there is no databases.', async function(): Promise { + mockDatabaseService.setup((o) => o.getDatabases(mockSubscription, mockCredentials)).returns(() => Promise.resolve(undefined)); + + const databaseContainerTreeNode = new AzureResourceDatabaseContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + + const children = await databaseContainerTreeNode.getChildren(); + + should(children).Array(); + should(children.length).equal(1); + should(children[0]).instanceof(AzureResourceMessageTreeNode); + should(children[0].nodePathValue).startWith('message_'); + should(children[0].getNodeInfo().label).equal('No SQL Databases found.'); + }); + + it('Should handle errors.', async function(): Promise { + const mockError = 'Test error'; + mockDatabaseService.setup((o) => o.getDatabases(mockSubscription, mockCredentials)).returns(() => { throw new Error(mockError); }); + + const databaseContainerTreeNode = new AzureResourceDatabaseContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + const children = await databaseContainerTreeNode.getChildren(); + + mockCredentialService.verify((o) => o.getCredentials(mockAccount), TypeMoq.Times.once()); + mockDatabaseService.verify((o) => o.getDatabases(mockSubscription, mockCredentials), TypeMoq.Times.once()); + mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.never()); + mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.never()); + + should(children).Array(); + should(children.length).equal(1); + should(children[0]).instanceof(AzureResourceMessageTreeNode); + should(children[0].nodePathValue).startWith('message_'); + should(children[0].getNodeInfo().label).equal(`Error: ${mockError}`); + }); +}); + +describe('AzureResourceDatabaseContainerTreeNode.clearCache', function() : void { + beforeEach(() => { + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + }); + + it('Should clear cache.', async function(): Promise { + const databaseContainerTreeNode = new AzureResourceDatabaseContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + databaseContainerTreeNode.clearCache(); + should(databaseContainerTreeNode.isClearingCache).true(); + }); +}); diff --git a/extensions/azurecore/src/test/azureResource/tree/databaseServerContainerTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/tree/databaseServerContainerTreeNode.test.ts new file mode 100644 index 0000000000..5bbed2f812 --- /dev/null +++ b/extensions/azurecore/src/test/azureResource/tree/databaseServerContainerTreeNode.test.ts @@ -0,0 +1,219 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import * as TypeMoq from 'typemoq'; +import * as sqlops from 'sqlops'; +import * as vscode from 'vscode'; +import 'mocha'; +import { ServiceClientCredentials } from 'ms-rest'; + +import { AzureResourceServicePool } from '../../../azureResource/servicePool'; +import { + IAzureResourceCacheService, + IAzureResourceContextService, + IAzureResourceCredentialService, + IAzureResourceDatabaseServerService +} from '../../../azureResource/interfaces'; +import { IAzureResourceTreeChangeHandler } from '../../../azureResource/tree/treeChangeHandler'; +import { AzureResourceSubscription, AzureResourceDatabaseServer } from '../../../azureResource/models'; +import { AzureResourceItemType } from '../../../azureResource/constants'; +import { AzureResourceMessageTreeNode } from '../../../azureResource/tree/messageTreeNode'; +import { AzureResourceDatabaseServerContainerTreeNode } from '../../../azureResource/tree/databaseServerContainerTreeNode'; +import { AzureResourceDatabaseServerTreeNode } from '../../../azureResource/tree/databaseServerTreeNode'; + +// Mock services +const mockServicePool = AzureResourceServicePool.getInstance(); + +let mockCacheService: TypeMoq.IMock; +let mockContextService: TypeMoq.IMock; +let mockCredentialService: TypeMoq.IMock; +let mockDatabaseServerService: TypeMoq.IMock; + +let mockTreeChangeHandler: TypeMoq.IMock; + +// Mock test data +const mockAccount: sqlops.Account = { + key: { + accountId: 'mock_account', + providerId: 'mock_provider' + }, + displayInfo: { + displayName: 'mock_account@test.com', + accountType: 'Microsoft', + contextualDisplayName: 'test' + }, + properties: undefined, + isStale: false +}; + +const mockCredential = TypeMoq.Mock.ofType().object; +const mockCredentials = [mockCredential]; + +const mockSubscription: AzureResourceSubscription = { + id: 'mock_subscription', + name: 'mock subscription' +}; + +const mockDatabaseServer1: AzureResourceDatabaseServer = { + name: 'mock server 1', + fullName: 'mock server 1', + loginName: 'mock user 1', + defaultDatabaseName: 'master' +}; +const mockDatabaseServer2: AzureResourceDatabaseServer = { + name: 'mock server 2', + fullName: 'mock server 2', + loginName: 'mock user 2', + defaultDatabaseName: 'master' +}; +const mockDatabaseServers = [mockDatabaseServer1, mockDatabaseServer2]; + +let mockDatabaseServerContainerCache: { databaseServers: { [subscriptionId: string]: AzureResourceDatabaseServer[] } }; + +describe('AzureResourceDatabaseServerContainerTreeNode.info', function(): void { + beforeEach(() => { + mockContextService = TypeMoq.Mock.ofType(); + + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + + mockServicePool.contextService = mockContextService.object; + }); + + it('Should be correct when created.', async function(): Promise { + const databaseServerContainerTreeNode = new AzureResourceDatabaseServerContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + + const databaseServerContainerTreeNodeLabel = 'SQL Servers'; + + should(databaseServerContainerTreeNode.nodePathValue).equal('databaseServerContainer'); + + const treeItem = await databaseServerContainerTreeNode.getTreeItem(); + should(treeItem.label).equal(databaseServerContainerTreeNodeLabel); + should(treeItem.contextValue).equal(AzureResourceItemType.databaseServerContainer); + should(treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.Collapsed); + + const nodeInfo = databaseServerContainerTreeNode.getNodeInfo(); + should(nodeInfo.isLeaf).false(); + should(nodeInfo.label).equal(databaseServerContainerTreeNodeLabel); + should(nodeInfo.nodeType).equal(AzureResourceItemType.databaseServerContainer); + should(nodeInfo.iconType).equal(AzureResourceItemType.databaseServerContainer); + }); +}); + +describe('AzureResourceDatabaseServerContainerTreeNode.getChildren', function(): void { + beforeEach(() => { + mockCacheService = TypeMoq.Mock.ofType(); + mockCredentialService = TypeMoq.Mock.ofType(); + mockDatabaseServerService = TypeMoq.Mock.ofType(); + + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + + mockDatabaseServerContainerCache = { databaseServers: {} }; + + mockServicePool.cacheService = mockCacheService.object; + mockServicePool.credentialService = mockCredentialService.object; + mockServicePool.databaseServerService = mockDatabaseServerService.object; + + mockCredentialService.setup((o) => o.getCredentials(mockAccount)).returns(() => Promise.resolve(mockCredentials)); + mockCacheService.setup((o) => o.get(TypeMoq.It.isAnyString())).returns(() => mockDatabaseServerContainerCache); + mockCacheService.setup((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny())).returns(() => mockDatabaseServerContainerCache.databaseServers[mockSubscription.id] = mockDatabaseServers); + }); + + it('Should load database servers from scratch and update cache when it is clearing cache.', async function(): Promise { + mockDatabaseServerService.setup((o) => o.getDatabaseServers(mockSubscription, mockCredentials)).returns(() => Promise.resolve(mockDatabaseServers)); + + const databaseServerContainerTreeNode = new AzureResourceDatabaseServerContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + + const children = await databaseServerContainerTreeNode.getChildren(); + + mockCredentialService.verify((o) => o.getCredentials(mockAccount), TypeMoq.Times.once()); + mockDatabaseServerService.verify((o) => o.getDatabaseServers(mockSubscription, mockCredentials), TypeMoq.Times.once()); + mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.once()); + mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.once()); + + should(databaseServerContainerTreeNode.isClearingCache).false(); + + should(children).Array(); + should(children.length).equal(mockDatabaseServers.length); + + should(Object.keys(mockDatabaseServerContainerCache.databaseServers)).deepEqual([mockSubscription.id]); + should(mockDatabaseServerContainerCache.databaseServers[mockSubscription.id]).deepEqual(mockDatabaseServers); + + for (let ix = 0; ix < mockDatabaseServers.length; ix++) { + const child = children[ix]; + const databaseServer = mockDatabaseServers[ix]; + + should(child).instanceof(AzureResourceDatabaseServerTreeNode); + should(child.nodePathValue).equal(`databaseServer_${databaseServer.name}`); + } + }); + + it('Should load database servers from cache when it is not clearing cache.', async function(): Promise { + mockDatabaseServerService.setup((o) => o.getDatabaseServers(mockSubscription, mockCredentials)).returns(() => Promise.resolve(mockDatabaseServers)); + + const databaseServerContainerTreeNode = new AzureResourceDatabaseServerContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + + await databaseServerContainerTreeNode.getChildren(); + const children = await databaseServerContainerTreeNode.getChildren(); + + mockCredentialService.verify((o) => o.getCredentials(mockAccount), TypeMoq.Times.exactly(1)); + mockDatabaseServerService.verify((o) => o.getDatabaseServers(mockSubscription, mockCredentials), TypeMoq.Times.exactly(1)); + mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.exactly(2)); + mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.exactly(1)); + + should(children.length).equal(mockDatabaseServerContainerCache.databaseServers[mockSubscription.id].length); + + for (let ix = 0; ix < mockDatabaseServerContainerCache.databaseServers[mockSubscription.id].length; ix++) { + should(children[ix].nodePathValue).equal(`databaseServer_${mockDatabaseServerContainerCache.databaseServers[mockSubscription.id][ix].name}`); + } + }); + + it('Should handle when there is no database servers.', async function(): Promise { + mockDatabaseServerService.setup((o) => o.getDatabaseServers(mockSubscription, mockCredentials)).returns(() => Promise.resolve(undefined)); + + const databaseContainerTreeNode = new AzureResourceDatabaseServerContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + + const children = await databaseContainerTreeNode.getChildren(); + + should(children).Array(); + should(children.length).equal(1); + should(children[0]).instanceof(AzureResourceMessageTreeNode); + should(children[0].nodePathValue).startWith('message_'); + should(children[0].getNodeInfo().label).equal('No SQL Servers found.'); + }); + + it('Should handle errors.', async function(): Promise { + const mockError = 'Test error'; + mockDatabaseServerService.setup((o) => o.getDatabaseServers(mockSubscription, mockCredentials)).returns(() => { throw new Error(mockError); }); + + const databaseServerContainerTreeNode = new AzureResourceDatabaseServerContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + const children = await databaseServerContainerTreeNode.getChildren(); + + mockCredentialService.verify((o) => o.getCredentials(mockAccount), TypeMoq.Times.once()); + mockDatabaseServerService.verify((o) => o.getDatabaseServers(mockSubscription, mockCredentials), TypeMoq.Times.once()); + mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.never()); + mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.never()); + + should(children).Array(); + should(children.length).equal(1); + should(children[0]).instanceof(AzureResourceMessageTreeNode); + should(children[0].nodePathValue).startWith('message_'); + should(children[0].getNodeInfo().label).equal(`Error: ${mockError}`); + }); +}); + +describe('AzureResourceDatabaseServerContainerTreeNode.clearCache', function() : void { + beforeEach(() => { + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + }); + + it('Should clear cache.', async function(): Promise { + const databaseServerContainerTreeNode = new AzureResourceDatabaseServerContainerTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + databaseServerContainerTreeNode.clearCache(); + should(databaseServerContainerTreeNode.isClearingCache).true(); + }); +}); diff --git a/extensions/azurecore/src/test/azureResource/tree/databaseServerTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/tree/databaseServerTreeNode.test.ts new file mode 100644 index 0000000000..cef163b670 --- /dev/null +++ b/extensions/azurecore/src/test/azureResource/tree/databaseServerTreeNode.test.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import * as TypeMoq from 'typemoq'; +import * as vscode from 'vscode'; +import 'mocha'; + +import { AzureResourceServicePool } from '../../../azureResource/servicePool'; +import { IAzureResourceContextService } from '../../../azureResource/interfaces'; +import { IAzureResourceTreeChangeHandler } from '../../../azureResource/tree/treeChangeHandler'; +import { AzureResourceDatabaseServer } from '../../../azureResource/models'; +import { AzureResourceItemType } from '../../../azureResource/constants'; +import { AzureResourceDatabaseServerTreeNode } from '../../../azureResource/tree/databaseServerTreeNode'; + +// Mock services +const mockServicePool = AzureResourceServicePool.getInstance(); + +let mockContextService: TypeMoq.IMock; + +let mockTreeChangeHandler: TypeMoq.IMock; + +// Mock test data +const mockDatabaseServer: AzureResourceDatabaseServer = { + name: 'mock database 1', + fullName: 'mock server 1', + loginName: 'mock user 1', + defaultDatabaseName: 'master' +}; + +describe('AzureResourceDatabaseServerTreeNode.info', function(): void { + beforeEach(() => { + mockContextService = TypeMoq.Mock.ofType(); + + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + + mockServicePool.contextService = mockContextService.object; + }); + + it('Should be correct when created.', async function(): Promise { + const databaseServerTreeNode = new AzureResourceDatabaseServerTreeNode(mockDatabaseServer, mockTreeChangeHandler.object, undefined); + + const databaseServerTreeNodeLabel = mockDatabaseServer.name; + + should(databaseServerTreeNode.nodePathValue).equal(`databaseServer_${mockDatabaseServer.name}`); + + const treeItem = await databaseServerTreeNode.getTreeItem(); + should(treeItem.label).equal(databaseServerTreeNodeLabel); + should(treeItem.contextValue).equal(AzureResourceItemType.databaseServer); + should(treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.None); + + const nodeInfo = databaseServerTreeNode.getNodeInfo(); + should(nodeInfo.isLeaf).true(); + should(nodeInfo.label).equal(databaseServerTreeNodeLabel); + should(nodeInfo.nodeType).equal(AzureResourceItemType.databaseServer); + should(nodeInfo.iconType).equal(AzureResourceItemType.databaseServer); + }); +}); diff --git a/extensions/azurecore/src/test/azureResource/tree/databaseTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/tree/databaseTreeNode.test.ts new file mode 100644 index 0000000000..a1350771b8 --- /dev/null +++ b/extensions/azurecore/src/test/azureResource/tree/databaseTreeNode.test.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import * as TypeMoq from 'typemoq'; +import * as vscode from 'vscode'; +import 'mocha'; + +import { AzureResourceServicePool } from '../../../azureResource/servicePool'; +import { IAzureResourceContextService } from '../../../azureResource/interfaces'; +import { IAzureResourceTreeChangeHandler } from '../../../azureResource/tree/treeChangeHandler'; +import { AzureResourceDatabase } from '../../../azureResource/models'; +import { AzureResourceItemType } from '../../../azureResource/constants'; +import { AzureResourceDatabaseTreeNode } from '../../../azureResource/tree/databaseTreeNode'; + +// Mock services +const mockServicePool = AzureResourceServicePool.getInstance(); + +let mockContextService: TypeMoq.IMock; + +let mockTreeChangeHandler: TypeMoq.IMock; + +// Mock test data +const mockDatabase: AzureResourceDatabase = { + name: 'mock database 1', + serverName: 'mock server 1', + serverFullName: 'mock server 1', + loginName: 'mock user 1' +}; + +describe('AzureResourceDatabaseTreeNode.info', function(): void { + beforeEach(() => { + mockContextService = TypeMoq.Mock.ofType(); + + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + + mockServicePool.contextService = mockContextService.object; + }); + + it('Should be correct.', async function(): Promise { + const databaseTreeNode = new AzureResourceDatabaseTreeNode(mockDatabase, mockTreeChangeHandler.object, undefined); + + const databaseTreeNodeLabel = `${mockDatabase.name} (${mockDatabase.serverName})`; + + should(databaseTreeNode.nodePathValue).equal(`database_${mockDatabase.name}`); + + const treeItem = await databaseTreeNode.getTreeItem(); + should(treeItem.label).equal(databaseTreeNodeLabel); + should(treeItem.contextValue).equal(AzureResourceItemType.database); + should(treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.None); + + const nodeInfo = databaseTreeNode.getNodeInfo(); + should(nodeInfo.isLeaf).true(); + should(nodeInfo.label).equal(databaseTreeNodeLabel); + should(nodeInfo.nodeType).equal(AzureResourceItemType.database); + should(nodeInfo.iconType).equal(AzureResourceItemType.database); + }); +}); diff --git a/extensions/azurecore/src/test/azureResource/tree/messageTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/tree/messageTreeNode.test.ts new file mode 100644 index 0000000000..ddd867c60f --- /dev/null +++ b/extensions/azurecore/src/test/azureResource/tree/messageTreeNode.test.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import * as vscode from 'vscode'; +import 'mocha'; + +import { AzureResourceItemType } from '../../../azureResource/constants'; +import { AzureResourceMessageTreeNode } from '../../../azureResource/tree/messageTreeNode'; + +describe('AzureResourceMessageTreeNode.info', function(): void { + it('Should be correct when created.', async function(): Promise { + const mockMessage = 'Test messagse'; + const treeNode = new AzureResourceMessageTreeNode(mockMessage, undefined); + + should(treeNode.nodePathValue).startWith('message_'); + + const treeItem = await treeNode.getTreeItem(); + should(treeItem.label).equal(mockMessage); + should(treeItem.contextValue).equal(AzureResourceItemType.message); + should(treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.None); + + const nodeInfo = treeNode.getNodeInfo(); + should(nodeInfo.isLeaf).true(); + should(nodeInfo.label).equal(mockMessage); + should(nodeInfo.nodeType).equal(AzureResourceItemType.message); + should(nodeInfo.iconType).equal(AzureResourceItemType.message); + }); +}); + +describe('AzureResourceMessageTreeNode.create', function(): void { + it('Should create a message node.', async function(): Promise { + const mockMessage = 'Test messagse'; + const treeNode = AzureResourceMessageTreeNode.create(mockMessage, undefined); + should(treeNode).instanceof(AzureResourceMessageTreeNode); + }); +}); diff --git a/extensions/azurecore/src/test/azureResource/tree/subscriptionTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/tree/subscriptionTreeNode.test.ts new file mode 100644 index 0000000000..e55621dfed --- /dev/null +++ b/extensions/azurecore/src/test/azureResource/tree/subscriptionTreeNode.test.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import * as TypeMoq from 'typemoq'; +import * as sqlops from 'sqlops'; +import * as vscode from 'vscode'; +import 'mocha'; + +import { AzureResourceServicePool } from '../../../azureResource/servicePool'; +import { IAzureResourceContextService } from '../../../azureResource/interfaces'; +import { IAzureResourceTreeChangeHandler } from '../../../azureResource/tree/treeChangeHandler'; +import { AzureResourceSubscription } from '../../../azureResource/models'; +import { AzureResourceSubscriptionTreeNode } from '../../../azureResource/tree/subscriptionTreeNode'; +import { AzureResourceDatabaseContainerTreeNode } from '../../../azureResource/tree/databaseContainerTreeNode'; +import { AzureResourceDatabaseServerContainerTreeNode } from '../../../azureResource/tree/databaseServerContainerTreeNode'; +import { AzureResourceItemType } from '../../../azureResource/constants'; + +// Mock services +const mockServicePool = AzureResourceServicePool.getInstance(); + +let mockContextService: TypeMoq.IMock; + +let mockTreeChangeHandler: TypeMoq.IMock; + +// Mock test data +const mockAccount: sqlops.Account = { + key: { + accountId: 'mock_account', + providerId: 'mock_provider' + }, + displayInfo: { + displayName: 'mock_account@test.com', + accountType: 'Microsoft', + contextualDisplayName: 'test' + }, + properties: undefined, + isStale: false +}; + +const mockSubscription: AzureResourceSubscription = { + id: 'mock_subscription', + name: 'mock subscription' +}; + +describe('AzureResourceSubscriptionTreeNode.info', function(): void { + beforeEach(() => { + mockContextService = TypeMoq.Mock.ofType(); + + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + + mockServicePool.contextService = mockContextService.object; + }); + + it('Should be correct when created.', async function(): Promise { + const subscriptionTreeNode = new AzureResourceSubscriptionTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + + should(subscriptionTreeNode.nodePathValue).equal(`subscription_${mockSubscription.id}`); + + const treeItem = await subscriptionTreeNode.getTreeItem(); + should(treeItem.label).equal(mockSubscription.name); + should(treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.Collapsed); + should(treeItem.contextValue).equal(AzureResourceItemType.subscription); + + const nodeInfo = subscriptionTreeNode.getNodeInfo(); + should(nodeInfo.label).equal(mockSubscription.name); + should(nodeInfo.isLeaf).equal(false); + should(nodeInfo.nodeType).equal(AzureResourceItemType.subscription); + should(nodeInfo.iconType).equal(AzureResourceItemType.subscription); + }); +}); + +describe('AzureResourceSubscriptionTreeNode.getChildren', function(): void { + beforeEach(() => { + mockTreeChangeHandler = TypeMoq.Mock.ofType(); + }); + + it('Should load database containers.', async function(): Promise { + const subscriptionTreeNode = new AzureResourceSubscriptionTreeNode(mockSubscription, mockAccount, mockTreeChangeHandler.object, undefined); + const children = await subscriptionTreeNode.getChildren(); + + should(children).Array(); + should(children.length).equal(2); + should(children[0]).instanceof(AzureResourceDatabaseContainerTreeNode); + should(children[1]).instanceof(AzureResourceDatabaseServerContainerTreeNode); + }); +}); diff --git a/extensions/azurecore/src/test/azureResource/tree/treeProvider.test.ts b/extensions/azurecore/src/test/azureResource/tree/treeProvider.test.ts new file mode 100644 index 0000000000..f8cf33c84c --- /dev/null +++ b/extensions/azurecore/src/test/azureResource/tree/treeProvider.test.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import * as TypeMoq from 'typemoq'; +import * as sqlops from 'sqlops'; +import 'mocha'; + +import { AzureResourceServicePool } from '../../../azureResource/servicePool'; +import { IAzureResourceAccountService } from '../../../azureResource/interfaces'; +import { AzureResourceTreeProvider } from '../../../azureResource/tree/treeProvider'; +import { AzureResourceAccountTreeNode } from '../../../azureResource/tree/accountTreeNode'; +import { AzureResourceAccountNotSignedInTreeNode } from '../../../azureResource/tree/accountNotSignedInTreeNode'; +import { AzureResourceMessageTreeNode } from '../../../azureResource/tree/messageTreeNode'; + +// Mock services +const mockServicePool = AzureResourceServicePool.getInstance(); + +let mockAccountService: TypeMoq.IMock; + +// Mock test data +const mockAccount1: sqlops.Account = { + key: { + accountId: 'mock_account_1', + providerId: 'mock_provider' + }, + displayInfo: { + displayName: 'mock_account_1@test.com', + accountType: 'Microsoft', + contextualDisplayName: 'test' + }, + properties: undefined, + isStale: false +}; +const mockAccount2: sqlops.Account = { + key: { + accountId: 'mock_account_2', + providerId: 'mock_provider' + }, + displayInfo: { + displayName: 'mock_account_2@test.com', + accountType: 'Microsoft', + contextualDisplayName: 'test' + }, + properties: undefined, + isStale: false +}; +const mockAccounts = [mockAccount1, mockAccount2]; + +describe('AzureResourceTreeProvider.getChildren', function(): void { + beforeEach(() => { + mockAccountService = TypeMoq.Mock.ofType(); + + mockServicePool.accountService = mockAccountService.object; + }); + + it('Should load accounts.', async function(): Promise { + mockAccountService.setup((o) => o.getAccounts()).returns(() => Promise.resolve(mockAccounts)); + + const treeProvider = new AzureResourceTreeProvider(); + treeProvider.isSystemInitialized = true; + + const children = await treeProvider.getChildren(undefined); + + mockAccountService.verify((o) => o.getAccounts(), TypeMoq.Times.once()); + + should(children).Array(); + should(children.length).equal(mockAccounts.length); + + for (let ix = 0; ix < mockAccounts.length; ix++) { + const child = children[ix]; + const account = mockAccounts[ix]; + + should(child).instanceof(AzureResourceAccountTreeNode); + should(child.nodePathValue).equal(`account_${account.key.accountId}`); + } + }); + + it('Should handle when there is no accounts.', async function(): Promise { + mockAccountService.setup((o) => o.getAccounts()).returns(() => Promise.resolve(undefined)); + + const treeProvider = new AzureResourceTreeProvider(); + treeProvider.isSystemInitialized = true; + + const children = await treeProvider.getChildren(undefined); + + should(children).Array(); + should(children.length).equal(1); + should(children[0]).instanceof(AzureResourceAccountNotSignedInTreeNode); + }); + + it('Should handle errors.', async function(): Promise { + const mockAccountError = 'Test account error'; + mockAccountService.setup((o) => o.getAccounts()).returns(() => { throw new Error(mockAccountError); }); + + const treeProvider = new AzureResourceTreeProvider(); + treeProvider.isSystemInitialized = true; + + const children = await treeProvider.getChildren(undefined); + + mockAccountService.verify((o) => o.getAccounts(), TypeMoq.Times.once()); + + should(children).Array(); + should(children.length).equal(1); + should(children[0]).instanceof(AzureResourceMessageTreeNode); + should(children[0].nodePathValue).startWith('message_'); + should(children[0].getNodeInfo().label).equal(`Error: ${mockAccountError}`); + }); +}); diff --git a/extensions/azurecore/src/test/index.ts b/extensions/azurecore/src/test/index.ts new file mode 100644 index 0000000000..a9f0ffbd82 --- /dev/null +++ b/extensions/azurecore/src/test/index.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +const path = require('path'); +const testRunner = require('vscode/lib/testrunner'); + +const suite = 'Integration Azure Tests'; + +const options: any = { + ui: 'bdd', + useColors: true, + timeout: 60000 +}; + +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { + options.reporter = 'mocha-multi-reporters'; + options.reporterOptions = { + reporterEnabled: 'spec, mocha-junit-reporter', + mochaJunitReporterReporterOptions: { + testsuitesTitle: `${suite} ${process.platform}`, + mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + } + }; +} + +testRunner.configure(options); + +export = testRunner; diff --git a/extensions/azurecore/src/treeNodes.ts b/extensions/azurecore/src/treeNodes.ts new file mode 100644 index 0000000000..7136ba98bd --- /dev/null +++ b/extensions/azurecore/src/treeNodes.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as sqlops from 'sqlops'; +import * as vscode from 'vscode'; + +type TreeNodePredicate = (node: TreeNode) => boolean; + +export abstract class TreeNode { + private _parent: TreeNode = undefined; + + public get parent(): TreeNode { + return this._parent; + } + + public set parent(node: TreeNode) { + this._parent = node; + } + + public generateNodePath(): string { + let path = undefined; + if (this.parent) { + path = this.parent.generateNodePath(); + } + path = path ? `${path}/${this.nodePathValue}` : this.nodePathValue; + return path; + } + + public findNodeByPath(path: string, expandIfNeeded: boolean = false): Promise { + let condition: TreeNodePredicate = (node: TreeNode) => node.getNodeInfo().nodePath === path; + let filter: TreeNodePredicate = (node: TreeNode) => path.startsWith(node.getNodeInfo().nodePath); + return TreeNode.findNode(this, condition, filter, true); + } + + public static async findNode(node: TreeNode, condition: TreeNodePredicate, filter: TreeNodePredicate, expandIfNeeded: boolean): Promise { + if (!node) { + return undefined; + } + + if (condition(node)) { + return node; + } + + let nodeInfo = node.getNodeInfo(); + if (nodeInfo.isLeaf) { + return undefined; + } + + // TODO support filtering by already expanded / not yet expanded + let children = await node.getChildren(false); + if (children) { + for (let child of children) { + if (filter && filter(child)) { + let childNode = await this.findNode(child, condition, filter, expandIfNeeded); + if (childNode) { + return childNode; + } + } + } + } + return undefined; + } + + /** + * The value to use for this node in the node path + */ + public abstract get nodePathValue(): string; + + abstract getChildren(refreshChildren: boolean): TreeNode[] | Promise; + abstract getTreeItem(): vscode.TreeItem | Promise; + + abstract getNodeInfo(): sqlops.NodeInfo; +} diff --git a/extensions/azurecore/src/typings/ref.d.ts b/extensions/azurecore/src/typings/ref.d.ts new file mode 100644 index 0000000000..41e273db7f --- /dev/null +++ b/extensions/azurecore/src/typings/ref.d.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/// +/// +/// +/// \ No newline at end of file diff --git a/extensions/azurecore/tsconfig.json b/extensions/azurecore/tsconfig.json new file mode 100644 index 0000000000..b341a65dab --- /dev/null +++ b/extensions/azurecore/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "outDir": "./out", + "lib": [ + "es6", "es2015.promise" + ], + "typeRoots": [ + "./node_modules/@types" + ], + "sourceMap": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "declaration": true + }, + "exclude": [ + "node_modules" + ] +} diff --git a/extensions/azurecore/yarn.lock b/extensions/azurecore/yarn.lock new file mode 100644 index 0000000000..8c2535a991 --- /dev/null +++ b/extensions/azurecore/yarn.lock @@ -0,0 +1,608 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/mocha@^5.2.5": + version "5.2.5" + resolved "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.5.tgz#8a4accfc403c124a0bafe8a9fc61a05ec1032073" + +"@types/node@^8.0.47": + version "8.10.30" + resolved "https://registry.npmjs.org/@types/node/-/node-8.10.30.tgz#2c82cbed5f79d72280c131d2acffa88fbd8dd353" + +adal-node@^0.1.28: + version "0.1.28" + resolved "https://registry.npmjs.org/adal-node/-/adal-node-0.1.28.tgz#468c4bb3ebbd96b1270669f4b9cba4e0065ea485" + dependencies: + "@types/node" "^8.0.47" + async ">=0.6.0" + date-utils "*" + jws "3.x.x" + request ">= 2.52.0" + underscore ">= 1.3.1" + uuid "^3.1.0" + xmldom ">= 0.1.x" + xpath.js "~1.1.0" + +ajv@^5.3.0: + version "5.5.2" + resolved "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" + dependencies: + co "^4.6.0" + fast-deep-equal "^1.0.0" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.3.0" + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + +async@2.6.0: + version "2.6.0" + resolved "https://registry.npmjs.org/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4" + dependencies: + lodash "^4.14.0" + +async@>=0.6.0: + version "2.6.1" + resolved "https://registry.npmjs.org/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" + dependencies: + lodash "^4.17.10" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + +aws4@^1.8.0: + version "1.8.0" + resolved "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" + +azure-arm-resource@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/azure-arm-resource/-/azure-arm-resource-7.0.0.tgz#e76fe2195abe354b607346c2fa0f690544176294" + dependencies: + ms-rest "^2.3.3" + ms-rest-azure "^2.5.5" + +azure-arm-sql@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/azure-arm-sql/-/azure-arm-sql-5.0.1.tgz#75c0b115525d2270ab16122d47d0c666aca175d4" + dependencies: + ms-rest "^2.3.3" + ms-rest-azure "^2.5.5" + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + dependencies: + tweetnacl "^0.14.3" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + +circular-json@^0.3.1: + version "0.3.3" + resolved "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + +combined-stream@1.0.6: + version "1.0.6" + resolved "http://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz#723e7df6e801ac5613113a7e445a9b69cb632818" + dependencies: + delayed-stream "~1.0.0" + +combined-stream@~1.0.6: + version "1.0.7" + resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" + dependencies: + delayed-stream "~1.0.0" + +commander@2.15.1: + version "2.15.1" + resolved "http://registry.npmjs.org/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + +core-util-is@1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + dependencies: + assert-plus "^1.0.0" + +date-utils@*: + version "1.2.21" + resolved "https://registry.npmjs.org/date-utils/-/date-utils-1.2.21.tgz#61fb16cdc1274b3c9acaaffe9fc69df8720a2b64" + +debug@3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + dependencies: + ms "2.0.0" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + +diff@3.5.0: + version "3.5.0" + resolved "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" + +duplexer@^0.1.1: + version "0.1.1" + resolved "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +ecdsa-sig-formatter@1.0.10: + version "1.0.10" + resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz#1c595000f04a8897dfb85000892a0f4c33af86c3" + dependencies: + safe-buffer "^5.0.1" + +escape-string-regexp@1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + +fast-deep-equal@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614" + +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + +form-data@~2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz#4970498be604c20c005d4f5c23aecd21d6b49099" + dependencies: + asynckit "^0.4.0" + combined-stream "1.0.6" + mime-types "^2.1.12" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + dependencies: + assert-plus "^1.0.0" + +glob@7.1.2: + version "7.1.2" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +growl@1.10.5: + version "1.10.5" + resolved "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + +har-validator@~5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/har-validator/-/har-validator-5.1.0.tgz#44657f5688a22cfd4b72486e81b3a3fb11742c29" + dependencies: + ajv "^5.3.0" + har-schema "^2.0.0" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + +he@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.3" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + +is-buffer@^1.1.6: + version "1.1.6" + resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + +json-schema-traverse@^0.3.0: + version "0.3.1" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +jwa@^1.1.5: + version "1.1.6" + resolved "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz#87240e76c9808dbde18783cf2264ef4929ee50e6" + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.10" + safe-buffer "^5.0.1" + +jws@3.x.x: + version "3.1.5" + resolved "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz#80d12d05b293d1e841e7cb8b4e69e561adcf834f" + dependencies: + jwa "^1.1.5" + safe-buffer "^5.0.1" + +lodash@^4.14.0, lodash@^4.17.10, lodash@^4.17.4: + version "4.17.11" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" + +mime-db@~1.36.0: + version "1.36.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.36.0.tgz#5020478db3c7fe93aad7bbcc4dcf869c43363397" + +mime-types@^2.1.12, mime-types@~2.1.19: + version "2.1.20" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.20.tgz#930cb719d571e903738520f8470911548ca2cc19" + dependencies: + mime-db "~1.36.0" + +minimatch@3.0.4, minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + dependencies: + brace-expansion "^1.1.7" + +minimist@0.0.8: + version "0.0.8" + resolved "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + +mkdirp@0.5.1: + version "0.5.1" + resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + dependencies: + minimist "0.0.8" + +mocha@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz#6d8ae508f59167f940f2b5b3c4a612ae50c90ae6" + dependencies: + browser-stdout "1.3.1" + commander "2.15.1" + debug "3.1.0" + diff "3.5.0" + escape-string-regexp "1.0.5" + glob "7.1.2" + growl "1.10.5" + he "1.1.1" + minimatch "3.0.4" + mkdirp "0.5.1" + supports-color "5.4.0" + +moment@^2.21.0, moment@^2.22.2: + version "2.22.2" + resolved "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" + +ms-rest-azure@^2.5.5: + version "2.5.9" + resolved "https://registry.npmjs.org/ms-rest-azure/-/ms-rest-azure-2.5.9.tgz#8599943e349c91eb367d2d1dcb885017518dc712" + dependencies: + adal-node "^0.1.28" + async "2.6.0" + moment "^2.22.2" + ms-rest "^2.3.2" + request "^2.88.0" + uuid "^3.2.1" + +ms-rest@^2.3.2, ms-rest@^2.3.3: + version "2.3.7" + resolved "https://registry.npmjs.org/ms-rest/-/ms-rest-2.3.7.tgz#8bfc82fb91807643fcaa487c5fc9698cd18a018c" + dependencies: + duplexer "^0.1.1" + is-buffer "^1.1.6" + is-stream "^1.1.0" + moment "^2.21.0" + request "^2.88.0" + through "^2.3.8" + tunnel "0.0.5" + uuid "^3.2.1" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + dependencies: + wrappy "1" + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + +postinstall-build@^5.0.1: + version "5.0.3" + resolved "https://registry.npmjs.org/postinstall-build/-/postinstall-build-5.0.3.tgz#238692f712a481d8f5bc8960e94786036241efc7" + +psl@^1.1.24: + version "1.1.29" + resolved "https://registry.npmjs.org/psl/-/psl-1.1.29.tgz#60f580d360170bb722a797cc704411e6da850c67" + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + +"request@>= 2.52.0", request@^2.88.0: + version "2.88.0" + resolved "https://registry.npmjs.org/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.0" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.4.3" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +safe-buffer@^5.0.1, safe-buffer@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + +safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + +should-equal@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3" + dependencies: + should-type "^1.4.0" + +should-format@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz#9bfc8f74fa39205c53d38c34d717303e277124f1" + dependencies: + should-type "^1.3.0" + should-type-adaptors "^1.0.1" + +should-type-adaptors@^1.0.1: + version "1.1.0" + resolved "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz#401e7f33b5533033944d5cd8bf2b65027792e27a" + dependencies: + should-type "^1.3.0" + should-util "^1.0.0" + +should-type@^1.3.0, should-type@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz#0756d8ce846dfd09843a6947719dfa0d4cff5cf3" + +should-util@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/should-util/-/should-util-1.0.0.tgz#c98cda374aa6b190df8ba87c9889c2b4db620063" + +should@^13.2.1: + version "13.2.3" + resolved "https://registry.npmjs.org/should/-/should-13.2.3.tgz#96d8e5acf3e97b49d89b51feaa5ae8d07ef58f10" + dependencies: + should-equal "^2.0.0" + should-format "^3.0.3" + should-type "^1.4.0" + should-type-adaptors "^1.0.1" + should-util "^1.0.0" + +sshpk@^1.7.0: + version "1.14.2" + resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz#c6fc61648a3d9c4e764fd3fcdf4ea105e492ba98" + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + dashdash "^1.12.0" + getpass "^0.1.1" + safer-buffer "^2.0.2" + optionalDependencies: + bcrypt-pbkdf "^1.0.0" + ecc-jsbn "~0.1.1" + jsbn "~0.1.0" + tweetnacl "~0.14.0" + +supports-color@5.4.0: + version "5.4.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz#1c6b337402c2137605efe19f10fec390f6faab54" + dependencies: + has-flag "^3.0.0" + +through@^2.3.8: + version "2.3.8" + resolved "http://registry.npmjs.org/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + +tough-cookie@~2.4.3: + version "2.4.3" + resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" + dependencies: + psl "^1.1.24" + punycode "^1.4.1" + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + dependencies: + safe-buffer "^5.0.1" + +tunnel@0.0.5: + version "0.0.5" + resolved "https://registry.npmjs.org/tunnel/-/tunnel-0.0.5.tgz#d1532254749ed36620fcd1010865495a1fa9d0ae" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + +typemoq@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/typemoq/-/typemoq-2.1.0.tgz#4452ce360d92cf2a1a180f0c29de2803f87af1e8" + dependencies: + circular-json "^0.3.1" + lodash "^4.17.4" + postinstall-build "^5.0.1" + +"underscore@>= 1.3.1": + version "1.9.1" + resolved "https://registry.npmjs.org/underscore/-/underscore-1.9.1.tgz#06dce34a0e68a7babc29b365b8e74b8925203961" + +uuid@^3.1.0, uuid@^3.2.1, uuid@^3.3.2: + version "3.3.2" + resolved "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +vscode-nls@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/vscode-nls/-/vscode-nls-4.0.0.tgz#4001c8a6caba5cedb23a9c5ce1090395c0e44002" + +wrappy@1: + version "1.0.2" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + +"xmldom@>= 0.1.x": + version "0.1.27" + resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9" + +xpath.js@~1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/xpath.js/-/xpath.js-1.1.0.tgz#3816a44ed4bb352091083d002a383dd5104a5ff1" diff --git a/extensions/mssql/package.json b/extensions/mssql/package.json index 7302c1915a..023e235259 100644 --- a/extensions/mssql/package.json +++ b/extensions/mssql/package.json @@ -93,7 +93,31 @@ "mssql.logDebugInfo": { "type": "boolean", "default": false, - "description": "[Optional] Log debug output to the VS Code console (Help -> Toggle Developer Tools)" + "description": "[Optional] Log debug output to the console (View -> Output) and then select appropriate output channel from the dropdown" + }, + "mssql.tracingLevel": { + "type": "string", + "description": "[Optional] Log level for backend services. Azure Data Studio generates a file name every time it starts and if the file already exists the logs entries are appended to that file. For cleanup of old log files see logRetentionMinutes and logFilesRemovalLimit settings. The default tracingLevel does not log much. Changing verbosity could lead to extensive logging and disk space requirements for the logs. Error includes Critical, Warning includes Error, Information includes Warning and Verbose includes Information", + "default": "Critical", + "enum": [ + "All", + "Off", + "Critical", + "Error", + "Warning", + "Information", + "Verbose" + ] + }, + "mssql.logRetentionMinutes": { + "type": "number", + "default": 10080, + "description": "Number of minutes to retain log files for backend services. Default is 1 week." + }, + "mssql.logFilesRemovalLimit": { + "type": "number", + "default": 100, + "description": "Maximum number of old files to remove upon startup that have expired mssql.logRetentionMinutes. Files that do not get cleaned up due to this limitation get cleaned up next time Azure Data Studio starts up." }, "ignorePlatformWarning": { "type": "boolean", diff --git a/extensions/mssql/src/config.json b/extensions/mssql/src/config.json index 6785710704..27028603aa 100644 --- a/extensions/mssql/src/config.json +++ b/extensions/mssql/src/config.json @@ -1,6 +1,6 @@ { "downloadUrl": "https://github.com/Microsoft/sqltoolsservice/releases/download/v{#version#}/microsoft.sqltools.servicelayer-{#fileName#}", - "version": "1.5.0-alpha.36", + "version": "1.5.0-alpha.41", "downloadFileNames": { "Windows_86": "win-x86-netcoreapp2.1.zip", "Windows_64": "win-x64-netcoreapp2.1.zip", diff --git a/extensions/mssql/src/constants.ts b/extensions/mssql/src/constants.ts index 0503be5bf1..1dc58ff589 100644 --- a/extensions/mssql/src/constants.ts +++ b/extensions/mssql/src/constants.ts @@ -9,5 +9,4 @@ export const providerId = 'MSSQL'; export const serviceCrashMessage = 'SQL Tools Service component exited unexpectedly. Please restart Azure Data Studio.'; export const serviceCrashButton = 'View Known Issues'; export const serviceCrashLink = 'https://github.com/Microsoft/vscode-mssql/wiki/SqlToolsService-Known-Issues'; -export const configLogDebugInfo = 'logDebugInfo'; -export const extensionConfigSectionName = 'mssql'; \ No newline at end of file +export const extensionConfigSectionName = 'mssql'; diff --git a/extensions/mssql/src/credentialstore/credentialstore.ts b/extensions/mssql/src/credentialstore/credentialstore.ts index 247c81f37d..b304551ae7 100644 --- a/extensions/mssql/src/credentialstore/credentialstore.ts +++ b/extensions/mssql/src/credentialstore/credentialstore.ts @@ -10,7 +10,6 @@ import { IConfig, ServerProvider } from 'service-downloader'; import { ServerOptions, RPCMessageType, ClientCapabilities, ServerCapabilities, TransportKind } from 'vscode-languageclient'; import { Disposable } from 'vscode'; import * as UUID from 'vscode-languageclient/lib/utils/uuid'; - import * as sqlops from 'sqlops'; import * as Contracts from './contracts'; @@ -100,11 +99,7 @@ export class CredentialStore { } private generateServerOptions(executablePath: string): ServerOptions { - let launchArgs = []; - launchArgs.push('--log-dir'); - let logFileLocation = path.join(Utils.getDefaultLogLocation(), 'mssql'); - launchArgs.push(logFileLocation); - + let launchArgs = Utils.getCommonLaunchArgsAndCleanupOldLogFiles('credentialstore', executablePath); return { command: executablePath, args: launchArgs, transport: TransportKind.stdio }; } } diff --git a/extensions/mssql/src/main.ts b/extensions/mssql/src/main.ts index 6261732942..401a6d3b15 100644 --- a/extensions/mssql/src/main.ts +++ b/extensions/mssql/src/main.ts @@ -97,18 +97,7 @@ export async function activate(context: vscode.ExtensionContext) { } function generateServerOptions(executablePath: string): ServerOptions { - let launchArgs = []; - launchArgs.push('--log-dir'); - let logFileLocation = path.join(Utils.getDefaultLogLocation(), 'mssql'); - launchArgs.push(logFileLocation); - let config = vscode.workspace.getConfiguration(Constants.extensionConfigSectionName); - if (config) { - let logDebugInfo = config[Constants.configLogDebugInfo]; - if (logDebugInfo) { - launchArgs.push('--enable-logging'); - } - } - + let launchArgs = Utils.getCommonLaunchArgsAndCleanupOldLogFiles('sqltools', executablePath); return { command: executablePath, args: launchArgs, transport: TransportKind.stdio }; } diff --git a/extensions/mssql/src/resourceProvider/resourceProvider.ts b/extensions/mssql/src/resourceProvider/resourceProvider.ts index fefe992dae..5cb23770a5 100644 --- a/extensions/mssql/src/resourceProvider/resourceProvider.ts +++ b/extensions/mssql/src/resourceProvider/resourceProvider.ts @@ -105,11 +105,7 @@ export class AzureResourceProvider { } private generateServerOptions(executablePath: string): ServerOptions { - let launchArgs = []; - launchArgs.push('--log-dir'); - let logFileLocation = path.join(Utils.getDefaultLogLocation(), 'mssql'); - launchArgs.push(logFileLocation); - + let launchArgs = Utils.getCommonLaunchArgsAndCleanupOldLogFiles('resourceprovider', executablePath); return { command: executablePath, args: launchArgs, transport: TransportKind.stdio }; } } diff --git a/extensions/mssql/src/utils.ts b/extensions/mssql/src/utils.ts index 0f4f8b932c..3ce734f3c3 100644 --- a/extensions/mssql/src/utils.ts +++ b/extensions/mssql/src/utils.ts @@ -7,6 +7,13 @@ import * as path from 'path'; import * as crypto from 'crypto'; import * as os from 'os'; +import {workspace, WorkspaceConfiguration} from 'vscode'; +import * as findRemoveSync from 'find-remove'; + +const configTracingLevel = 'tracingLevel'; +const configLogRetentionMinutes = 'logRetentionMinutes'; +const configLogFilesRemovalLimit = 'logFilesRemovalLimit'; +const extensionConfigSectionName = 'mssql'; // The function is a duplicate of \src\paths.js. IT would be better to import path.js but it doesn't // work for now because the extension is running in different process. @@ -20,8 +27,69 @@ export function getAppDataPath() { } } -export function getDefaultLogLocation() { - return path.join(getAppDataPath(), 'azuredatastudio'); +export function removeOldLogFiles(prefix: string) : JSON { + return findRemoveSync(getDefaultLogDir(), {prefix: `${prefix}_`, age: {seconds: getConfigLogRetentionSeconds()}, limit: getConfigLogFilesRemovalLimit()}); +} + +export function getConfiguration(config: string = extensionConfigSectionName) : WorkspaceConfiguration { + return workspace.getConfiguration(extensionConfigSectionName); +} + +export function getConfigLogFilesRemovalLimit() : number { + let config = getConfiguration(); + if (config) { + return Number((config[configLogFilesRemovalLimit]).toFixed(0)); + } + else + { + return undefined; + } +} + +export function getConfigLogRetentionSeconds() : number { + let config = getConfiguration(); + if (config) { + return Number((config[configLogRetentionMinutes] * 60).toFixed(0)); + } + else + { + return undefined; + } +} + +export function getConfigTracingLevel() : string { + let config = getConfiguration(); + if (config) { + return config[configTracingLevel]; + } + else + { + return undefined; + } +} + +export function getDefaultLogDir() : string { + return path.join(process.env['VSCODE_LOGS'], '..', '..','mssql'); +} + +export function getDefaultLogFile(prefix: string, pid: number) : string { + return path.join(getDefaultLogDir(), `${prefix}_${pid}.log`); +} + +export function getCommonLaunchArgsAndCleanupOldLogFiles(prefix: string, executablePath: string) : string [] { + let launchArgs = []; + launchArgs.push('--log-file'); + let logFile = getDefaultLogFile(prefix, process.pid); + launchArgs.push(logFile); + + console.log(`logFile for ${path.basename(executablePath)} is ${logFile}`); + console.log(`This process (ui Extenstion Host) is pid: ${process.pid}`); + // Delete old log files + let deletedLogFiles = removeOldLogFiles(prefix); + console.log(`Old log files deletion report: ${JSON.stringify(deletedLogFiles)}`); + launchArgs.push('--tracing-level'); + launchArgs.push(getConfigTracingLevel()); + return launchArgs; } export function ensure(target: object, key: string): any { diff --git a/package.json b/package.json index 228eb4976a..d9a5e85f50 100644 --- a/package.json +++ b/package.json @@ -38,10 +38,11 @@ "@angular/upgrade": "~4.1.3", "@types/chart.js": "^2.7.31", "angular2-grid": "2.0.6", - "angular2-slickgrid": "github:Microsoft/angular2-slickgrid#1.4.4", + "angular2-slickgrid": "github:Microsoft/angular2-slickgrid#1.4.5", "applicationinsights": "0.18.0", "chart.js": "^2.6.0", "fast-plist": "0.1.2", + "find-remove": "1.2.1", "fs-extra": "^3.0.1", "gc-signals": "^0.0.1", "getmac": "1.4.1", diff --git a/resources/darwin/code.icns b/resources/darwin/code.icns index a7cc8d2c88..4c28a05607 100644 Binary files a/resources/darwin/code.icns and b/resources/darwin/code.icns differ diff --git a/scripts/test-integration.bat b/scripts/test-integration.bat index 929b92b9bc..4e613f5592 100644 --- a/scripts/test-integration.bat +++ b/scripts/test-integration.bat @@ -6,8 +6,11 @@ pushd %~dp0\.. set VSCODEUSERDATADIR=%TMP%\vscodeuserfolder-%RANDOM%-%TIME:~6,5% :: Tests in the extension host -call .\scripts\code.bat %~dp0\..\extensions\vscode-api-tests\testWorkspace --extensionDevelopmentPath=%~dp0\..\extensions\vscode-api-tests --extensionTestsPath=%~dp0\..\extensions\vscode-api-tests\out --disableExtensions --user-data-dir=%VSCODEUSERDATADIR% +:: TODO port over an re-enable API tests +:: call .\scripts\code.bat %~dp0\..\extensions\vscode-api-tests\testWorkspace --extensionDevelopmentPath=%~dp0\..\extensions\vscode-api-tests --extensionTestsPath=%~dp0\..\extensions\vscode-api-tests\out --disableExtensions --user-data-dir=%VSCODEUSERDATADIR% call .\scripts\code.bat %~dp0\..\extensions\vscode-colorize-tests\test --extensionDevelopmentPath=%~dp0\..\extensions\vscode-colorize-tests --extensionTestsPath=%~dp0\..\extensions\vscode-colorize-tests\out --disableExtensions --user-data-dir=%VSCODEUSERDATADIR% +call .\scripts\code.bat %~dp0\..\extensions\markdown-language-features\test-fixtures --extensionDevelopmentPath=%~dp0\..\extensions\markdown-language-features --extensionTestsPath=%~dp0\..\extensions\markdown-language-features\out\test --disableExtensions --user-data-dir=%VSCODEUSERDATADIR% +call .\scripts\code.bat %~dp0\..\extensions\azure\test-fixtures --extensionDevelopmentPath=%~dp0\..\extensions\azure --extensionTestsPath=%~dp0\..\extensions\azure\out\test --disableExtensions --user-data-dir=%VSCODEUSERDATADIR% if %errorlevel% neq 0 exit /b %errorlevel% call .\scripts\code.bat $%~dp0\..\extensions\emmet\test-fixtures --extensionDevelopmentPath=%~dp0\..\extensions\emmet --extensionTestsPath=%~dp0\..\extensions\emmet\out\test --disableExtensions --user-data-dir=%VSCODEUSERDATADIR% . diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index a48257ad4f..fba91607f9 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -13,9 +13,11 @@ fi cd $ROOT # Tests in the extension host -./scripts/code.sh $ROOT/extensions/vscode-api-tests/testWorkspace --extensionDevelopmentPath=$ROOT/extensions/vscode-api-tests --extensionTestsPath=$ROOT/extensions/vscode-api-tests/out --disableExtensions --user-data-dir=$VSCODEUSERDATADIR --skip-getting-started +# TODO port over an re-enable API tests +# ./scripts/code.sh $ROOT/extensions/vscode-api-tests/testWorkspace --extensionDevelopmentPath=$ROOT/extensions/vscode-api-tests --extensionTestsPath=$ROOT/extensions/vscode-api-tests/out --disableExtensions --user-data-dir=$VSCODEUSERDATADIR --skip-getting-started ./scripts/code.sh $ROOT/extensions/vscode-colorize-tests/test --extensionDevelopmentPath=$ROOT/extensions/vscode-colorize-tests --extensionTestsPath=$ROOT/extensions/vscode-colorize-tests/out --disableExtensions --user-data-dir=$VSCODEUSERDATADIR --skip-getting-started ./scripts/code.sh $ROOT/extensions/markdown-language-features/test-fixtures --extensionDevelopmentPath=$ROOT/extensions/markdown-language-features --extensionTestsPath=$ROOT/extensions/markdown-language-features/out/test --disableExtensions --user-data-dir=$VSCODEUSERDATADIR --skip-getting-started +./scripts/code.sh $ROOT/extensions/azure/test-fixtures --extensionDevelopmentPath=$ROOT/extensions/azure --extensionTestsPath=$ROOT/extensions/azure/out/test --disableExtensions --user-data-dir=$VSCODEUSERDATADIR --skip-getting-started mkdir $ROOT/extensions/emmet/test-fixtures ./scripts/code.sh $ROOT/extensions/emmet/test-fixtures --extensionDevelopmentPath=$ROOT/extensions/emmet --extensionTestsPath=$ROOT/extensions/emmet/out/test --disableExtensions --user-data-dir=$VSCODEUSERDATADIR --skip-getting-started . diff --git a/src/sql/base/browser/ui/table/plugins/additionalKeyBindings.plugin.ts b/src/sql/base/browser/ui/table/plugins/additionalKeyBindings.plugin.ts index 5711f8c049..35d7e318bb 100644 --- a/src/sql/base/browser/ui/table/plugins/additionalKeyBindings.plugin.ts +++ b/src/sql/base/browser/ui/table/plugins/additionalKeyBindings.plugin.ts @@ -56,8 +56,6 @@ export class AdditionalKeyBindings implements Slick.Plugin { let selectionModel = this.grid.getSelectionModel(); if (selectionModel) { selectionModel.setSelectedRanges([new Slick.Range(0, 0, this.grid.getDataLength() - 1, this.grid.getColumns().length - 1)]); - } else { - this.grid.setSelectedRows(range(this.grid.getDataLength())); } } else { handled = false; diff --git a/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts b/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts index 9a821b1ea6..a24cbaddab 100644 --- a/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts +++ b/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts @@ -39,16 +39,26 @@ export class HeaderFilter { this.handler.subscribe(this.grid.onHeaderCellRendered, (e, args) => this.handleHeaderCellRendered(e , args)) .subscribe(this.grid.onBeforeHeaderCellDestroy, (e, args) => this.handleBeforeHeaderCellDestroy(e, args)) .subscribe(this.grid.onClick, (e) => this.handleBodyMouseDown) - .subscribe(this.grid.onColumnsResized, () => this.columnsResized()); - + .subscribe(this.grid.onColumnsResized, () => this.columnsResized()) + .subscribe(this.grid.onKeyDown, (e) => this.handleKeyDown); this.grid.setColumns(this.grid.getColumns()); $(document.body).bind('mousedown', this.handleBodyMouseDown); + $(document.body).bind('keydown', this.handleKeyDown); } public destroy() { this.handler.unsubscribeAll(); $(document.body).unbind('mousedown', this.handleBodyMouseDown); + $(document.body).unbind('keydown', this.handleKeyDown); + } + + private handleKeyDown = (e) => { + if (this.$menu && (e.key === 'Escape' || e.keyCode === 27)) { + this.hideMenu(); + e.preventDefault(); + e.stopPropagation(); + } } private handleBodyMouseDown = (e) => { diff --git a/src/sql/base/browser/ui/table/plugins/mousewheelTableScroll.plugin.ts b/src/sql/base/browser/ui/table/plugins/mousewheelTableScroll.plugin.ts index f45bd29b99..0e363938c0 100644 --- a/src/sql/base/browser/ui/table/plugins/mousewheelTableScroll.plugin.ts +++ b/src/sql/base/browser/ui/table/plugins/mousewheelTableScroll.plugin.ts @@ -6,6 +6,7 @@ 'use strict'; import * as DOM from 'vs/base/browser/dom'; +import * as Platform from 'vs/base/common/platform'; import { StandardMouseWheelEvent } from 'vs/base/browser/mouseEvent'; import { dispose, IDisposable } from 'vs/base/common/lifecycle'; import { mixin } from 'vs/base/common/objects'; @@ -29,7 +30,7 @@ export class MouseWheelSupport implements Slick.Plugin { private _disposables: IDisposable[] = []; constructor(options: IMouseWheelSupportOptions = {}) { - this.options = mixin(options, defaultOptions); + this.options = mixin(options, defaultOptions, false); } public init(grid: Slick.Grid): void { @@ -43,29 +44,69 @@ export class MouseWheelSupport implements Slick.Plugin { this._disposables.push(DOM.addDisposableListener(this.viewport, 'DOMMouseScroll', onMouseWheel)); } - private _onMouseWheel(event: StandardMouseWheelEvent) { - const scrollHeight = this.canvas.clientHeight; - const height = this.viewport.clientHeight; - const scrollDown = Math.sign(event.deltaY) === -1; - if (scrollDown) { - if ((this.viewport.scrollTop - (event.deltaY * this.options.scrollSpeed)) + height > scrollHeight) { - this.viewport.scrollTop = scrollHeight - height; - this.viewport.dispatchEvent(new Event('scroll')); - } else { - this.viewport.scrollTop = this.viewport.scrollTop - (event.deltaY * this.options.scrollSpeed); - this.viewport.dispatchEvent(new Event('scroll')); - event.stopPropagation(); - event.preventDefault(); + private _onMouseWheel(e: StandardMouseWheelEvent) { + if (e.deltaY || e.deltaX) { + let deltaY = e.deltaY * this.options.scrollSpeed; + let deltaX = e.deltaX * this.options.scrollSpeed; + const scrollHeight = this.canvas.clientHeight; + const scrollWidth = this.canvas.clientWidth; + const height = this.viewport.clientHeight; + const width = this.viewport.clientWidth; + + // Convert vertical scrolling to horizontal if shift is held, this + // is handled at a higher level on Mac + const shiftConvert = !Platform.isMacintosh && e.browserEvent && e.browserEvent.shiftKey; + if (shiftConvert && !deltaX) { + deltaX = deltaY; + deltaY = 0; } - } else { - if ((this.viewport.scrollTop - (event.deltaY * this.options.scrollSpeed)) < 0) { - this.viewport.scrollTop = 0; - this.viewport.dispatchEvent(new Event('scroll')); + + // scroll down + if (deltaY < 0) { + if ((this.viewport.scrollTop - deltaY) + height > scrollHeight) { + this.viewport.scrollTop = scrollHeight - height; + this.viewport.dispatchEvent(new Event('scroll')); + } else { + this.viewport.scrollTop = this.viewport.scrollTop - deltaY; + this.viewport.dispatchEvent(new Event('scroll')); + event.stopPropagation(); + event.preventDefault(); + } + // scroll up } else { - this.viewport.scrollTop = this.viewport.scrollTop - (event.deltaY * this.options.scrollSpeed); - this.viewport.dispatchEvent(new Event('scroll')); - event.stopPropagation(); - event.preventDefault(); + if ((this.viewport.scrollTop - deltaY) < 0) { + this.viewport.scrollTop = 0; + this.viewport.dispatchEvent(new Event('scroll')); + } else { + this.viewport.scrollTop = this.viewport.scrollTop - deltaY; + this.viewport.dispatchEvent(new Event('scroll')); + event.stopPropagation(); + event.preventDefault(); + } + } + + // scroll left + if (deltaX < 0) { + if ((this.viewport.scrollLeft - deltaX) + width > scrollWidth) { + this.viewport.scrollLeft = scrollWidth - width; + this.viewport.dispatchEvent(new Event('scroll')); + } else { + this.viewport.scrollLeft = this.viewport.scrollLeft - deltaX; + this.viewport.dispatchEvent(new Event('scroll')); + event.stopPropagation(); + event.preventDefault(); + } + // scroll left + } else { + if ((this.viewport.scrollLeft - deltaX) < 0) { + this.viewport.scrollLeft = 0; + this.viewport.dispatchEvent(new Event('scroll')); + } else { + this.viewport.scrollLeft = this.viewport.scrollLeft - deltaX; + this.viewport.dispatchEvent(new Event('scroll')); + event.stopPropagation(); + event.preventDefault(); + } } } } diff --git a/src/sql/base/browser/ui/table/plugins/rowNumberColumn.plugin.ts b/src/sql/base/browser/ui/table/plugins/rowNumberColumn.plugin.ts index 7806e083f4..547dcd15f3 100644 --- a/src/sql/base/browser/ui/table/plugins/rowNumberColumn.plugin.ts +++ b/src/sql/base/browser/ui/table/plugins/rowNumberColumn.plugin.ts @@ -45,8 +45,9 @@ export class RowNumberColumn implements Slick.Plugin { private handleHeaderClick(e: MouseEvent, args: Slick.OnHeaderClickEventArgs): void { if (args.column.id === 'rowNumber') { this.grid.setActiveCell(0, 1); - if (this.grid.getSelectionModel()) { - this.grid.setSelectedRows(range(this.grid.getDataLength())); + let selectionModel = this.grid.getSelectionModel(); + if (selectionModel) { + selectionModel.setSelectedRanges([new Slick.Range(0, 0, this.grid.getDataLength() - 1, this.grid.getColumns().length - 1)]); } } } diff --git a/src/sql/parts/connection/common/connectionConfig.ts b/src/sql/parts/connection/common/connectionConfig.ts index 83a858c86e..5d94f9dd85 100644 --- a/src/sql/parts/connection/common/connectionConfig.ts +++ b/src/sql/parts/connection/common/connectionConfig.ts @@ -84,13 +84,14 @@ export class ConnectionConfig implements IConnectionConfig { return providerConnectionProfile.matches(connectionProfile); }); if (sameProfileInList) { - profiles = profiles.filter(value => value !== sameProfileInList); + let profileIndex = profiles.findIndex(value => value === sameProfileInList); newProfile.id = sameProfileInList.id; connectionProfile.id = sameProfileInList.id; + profiles[profileIndex] = newProfile; + } else { + profiles.push(newProfile); } - profiles.push(newProfile); - this.writeConfiguration(Constants.connectionsArrayName, profiles).then(() => { resolve(connectionProfile); }).catch(err => { diff --git a/src/sql/parts/connection/common/connectionManagement.ts b/src/sql/parts/connection/common/connectionManagement.ts index 724d735d49..35339f1932 100644 --- a/src/sql/parts/connection/common/connectionManagement.ts +++ b/src/sql/parts/connection/common/connectionManagement.ts @@ -67,6 +67,7 @@ export interface IConnectionCallbacks { onConnectReject(error?: string): void; onConnectSuccess(params?: INewConnectionParams): void; onDisconnect(): void; + onConnectCanceled(): void; } export const SERVICE_ID = 'connectionManagementService'; @@ -336,6 +337,7 @@ export interface IConnectableInput { onConnectReject(error?: string): void; onConnectSuccess(params?: INewConnectionParams): void; onDisconnect(): void; + onConnectCanceled(): void; } export enum ConnectionType { diff --git a/src/sql/parts/connection/common/connectionManagementService.ts b/src/sql/parts/connection/common/connectionManagementService.ts index 9b057aa729..80f466fa9e 100644 --- a/src/sql/parts/connection/common/connectionManagementService.ts +++ b/src/sql/parts/connection/common/connectionManagementService.ts @@ -366,6 +366,7 @@ export class ConnectionManagementService extends Disposable implements IConnecti onConnectStart: callbacks ? callbacks.onConnectStart : undefined, onConnectSuccess: callbacks ? callbacks.onConnectSuccess : undefined, onDisconnect: callbacks ? callbacks.onDisconnect : undefined, + onConnectCanceled: callbacks ? callbacks.onConnectCanceled : undefined, uri: uri }; } @@ -438,7 +439,8 @@ export class ConnectionManagementService extends Disposable implements IConnecti onConnectReject: () => { }, onConnectStart: () => { }, onConnectSuccess: () => { }, - onDisconnect: () => { } + onDisconnect: () => { }, + onConnectCanceled: () => { } }; } if (!options) { @@ -1349,8 +1351,12 @@ export class ConnectionManagementService extends Disposable implements IConnecti * TODO this could be a map reduce operation */ public buildConnectionInfo(connectionString: string, provider: string): Thenable { - return this._providers.get(provider).onReady.then(e => { - return e.buildConnectionInfo(connectionString); - }); + let connectionProvider = this._providers.get(provider); + if (connectionProvider) { + return connectionProvider.onReady.then(e => { + return e.buildConnectionInfo(connectionString); + }); + } + return Promise.resolve(undefined); } } diff --git a/src/sql/parts/connection/connectionDialog/connectionDialogService.ts b/src/sql/parts/connection/connectionDialog/connectionDialogService.ts index ef88092c4c..e54ae207d4 100644 --- a/src/sql/parts/connection/connectionDialog/connectionDialogService.ts +++ b/src/sql/parts/connection/connectionDialog/connectionDialogService.ts @@ -153,6 +153,9 @@ export class ConnectionDialogService implements IConnectionDialogService { } else { this._connectionManagementService.cancelConnection(this._model); } + if (params && params.input && params.input.onConnectCanceled) { + params.input.onConnectCanceled(); + } this._connectionDialog.resetConnection(); this._connecting = false; } diff --git a/src/sql/parts/editData/common/editDataInput.ts b/src/sql/parts/editData/common/editDataInput.ts index c53df2f445..e69d3e465d 100644 --- a/src/sql/parts/editData/common/editDataInput.ts +++ b/src/sql/parts/editData/common/editDataInput.ts @@ -175,6 +175,9 @@ export class EditDataInput extends EditorInput implements IConnectableInput { } } + public onConnectCanceled(): void { + } + public onConnectSuccess(params?: INewConnectionParams): void { let rowLimit: number = undefined; let queryString: string = undefined; @@ -186,6 +189,7 @@ export class EditDataInput extends EditorInput implements IConnectableInput { this._queryModelService.initializeEdit(this.uri, this.schemaName, this.tableName, this._objectType, rowLimit, queryString); this.showResultsEditor(); + this._onDidChangeLabel.fire(); } public onDisconnect(): void { diff --git a/src/sql/parts/grid/views/editData/editData.component.html b/src/sql/parts/grid/views/editData/editData.component.html index 38df6df546..106fbd2e82 100644 --- a/src/sql/parts/grid/views/editData/editData.component.html +++ b/src/sql/parts/grid/views/editData/editData.component.html @@ -23,6 +23,7 @@ (onContextMenu)="openContextMenu($event, dataSet.batchId, dataSet.resultId, i)" [isCellEditValid]="onIsCellEditValid" [overrideCellFn]="overrideCellFn" + [onBeforeAppendCell]="onBeforeAppendCell" [enableEditing]="true" class="boxCol content vertBox slickgrid"> diff --git a/src/sql/parts/grid/views/editData/editData.component.ts b/src/sql/parts/grid/views/editData/editData.component.ts index 22035694e3..5c0ea466bd 100644 --- a/src/sql/parts/grid/views/editData/editData.component.ts +++ b/src/sql/parts/grid/views/editData/editData.component.ts @@ -39,6 +39,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { KeyCode } from 'vs/base/common/keyCodes'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { EditUpdateCellResult } from 'sqlops'; export const EDITDATA_SELECTOR: string = 'editdata-component'; @Component({ @@ -50,7 +51,10 @@ export const EDITDATA_SELECTOR: string = 'editdata-component'; export class EditDataComponent extends GridParentComponent implements OnInit, OnDestroy { // CONSTANTS private scrollTimeOutTime = 200; - private windowSize = 50; + + // Optimized for the edit top 200 rows scenario, only need to retrieve the data once + // to make the scroll experience smoother + private windowSize = 200; // FIELDS // All datasets @@ -67,6 +71,7 @@ export class EditDataComponent extends GridParentComponent implements OnInit, On private newRowVisible: boolean; private removingNewRow: boolean; private rowIdMappings: { [gridRowId: number]: number } = {}; + private dirtyCells: number[] = []; protected plugins = new Array>>(); // Edit Data functions @@ -76,6 +81,7 @@ export class EditDataComponent extends GridParentComponent implements OnInit, On public onIsColumnEditable: (column: number) => boolean; public overrideCellFn: (rowNumber, columnId, value?, data?) => string; public loadDataFunction: (offset: number, count: number) => Promise<{}[]>; + public onBeforeAppendCell: (row: number, column: number) => string; private savedViewState: { gridSelections: Slick.Range[]; @@ -179,6 +185,18 @@ export class EditDataComponent extends GridParentComponent implements OnInit, On return returnVal; }; + // This is the event slickgrid will raise in order to get the additional cell CSS classes for the cell + // Due to performance advantage we are using this event instead of the onViewportChanged event. + this.onBeforeAppendCell = (row: number, column: number): string => { + let cellClass = undefined; + if (this.isRowDirty(row) && column === 0) { + cellClass = ' dirtyCell '; + } else if (this.isCellDirty(row, column)) { + cellClass = ' dirtyRowHeader '; + } + return cellClass; + }; + // Setup a function for generating a promise to lookup result subsets this.loadDataFunction = (offset: number, count: number): Promise<{}[]> => { return new Promise<{}[]>((resolve, reject) => { @@ -204,17 +222,6 @@ export class EditDataComponent extends GridParentComponent implements OnInit, On }, {})); } - // let rowIndex = offset; - // let gridData: IGridDataRow[] = result.subset.map(row => { - // self.idMapping[rowIndex] = row.id; - // rowIndex++; - // return { - // values: [{}].concat(row.cells.map(c => { - // return mixin({ ariaLabel: escape(c.displayValue) }, c); - // })), row: row.id - // }; - // }); - resolve(gridData); }); }); @@ -258,33 +265,18 @@ export class EditDataComponent extends GridParentComponent implements OnInit, On return; } - let cellSelectTasks: Promise = Promise.resolve(); - - if (this.currentCell.isEditable && this.currentEditCellValue !== null && !this.removingNewRow) { - // We're exiting a read/write cell after having changed the value, update the cell value in the service - cellSelectTasks = cellSelectTasks.then(() => { - // Use the mapped row ID if we're on that row - let sessionRowId = self.rowIdMappings[self.currentCell.row] !== undefined - ? self.rowIdMappings[self.currentCell.row] - : self.currentCell.row; - - return self.dataService.updateCell(sessionRowId, self.currentCell.column - 1, self.currentEditCellValue) - .then( - result => { - // Cell update was successful, update the flags - self.currentEditCellValue = null; - self.setCellDirtyState(row, self.currentCell.column, result.cell.isDirty); - self.setRowDirtyState(row, result.isRowDirty); - return Promise.resolve(); - }, - error => { - // Cell update failed, jump back to the last cell we were on - self.focusCell(self.currentCell.row, self.currentCell.column, true); - return Promise.reject(null); - } - ); + let cellSelectTasks: Promise = this.submitCurrentCellChange( + (result: EditUpdateCellResult) => { + // Cell update was successful, update the flags + self.setCellDirtyState(row, self.currentCell.column, result.cell.isDirty); + self.setRowDirtyState(row, result.isRowDirty); + return Promise.resolve(); + }, + (error) => { + // Cell update failed, jump back to the last cell we were on + self.focusCell(self.currentCell.row, self.currentCell.column, true); + return Promise.reject(null); }); - } if (this.currentCell.row !== row) { // If we're currently adding a new row, only commit it if it has changes or the user is trying to add another new row @@ -474,11 +466,38 @@ export class EditDataComponent extends GridParentComponent implements OnInit, On // do not refresh the whole dataset as it will move the focus away to the first row. // this.currentEditCellValue = null; + this.dirtyCells = []; this.dataSet.dataRows.resetWindowsAroundIndex(this.currentCell.row); } } } + private submitCurrentCellChange(resultHandler, errorHandler): Promise { + let self = this; + let updateCellPromise: Promise = Promise.resolve(); + + if (this.currentCell && this.currentCell.isEditable && this.currentEditCellValue !== null && !this.removingNewRow) { + // We're exiting a read/write cell after having changed the value, update the cell value in the service + updateCellPromise = updateCellPromise.then(() => { + // Use the mapped row ID if we're on that row + let sessionRowId = self.rowIdMappings[self.currentCell.row] !== undefined + ? self.rowIdMappings[self.currentCell.row] + : self.currentCell.row; + + return self.dataService.updateCell(sessionRowId, self.currentCell.column - 1, self.currentEditCellValue); + }).then( + result => { + self.currentEditCellValue = null; + return resultHandler(result); + }, + error => { + return errorHandler(error); + } + ); + } + return updateCellPromise; + } + // Checks if input row is our NULL new row private isNullRow(row: number): boolean { // Null row is always at index (totalRows - 1) @@ -492,6 +511,9 @@ export class EditDataComponent extends GridParentComponent implements OnInit, On if (dirtyState) { // Change cell color $(grid.getCellNode(row, column)).addClass('dirtyCell').removeClass('selected'); + if (this.dirtyCells.indexOf(column) === -1) { + this.dirtyCells.push(column); + } } else { $(grid.getCellNode(row, column)).removeClass('dirtyCell'); } @@ -515,6 +537,7 @@ export class EditDataComponent extends GridParentComponent implements OnInit, On let allRows = $($('.grid-canvas').children()); let allCells = $(allRows.children()); allCells.removeClass('dirtyCell').removeClass('dirtyRowHeader'); + this.dirtyCells = []; } // Adds an extra row to the end of slickgrid (just for rendering purposes) @@ -594,16 +617,30 @@ export class EditDataComponent extends GridParentComponent implements OnInit, On } private saveViewState(): void { - let grid = this.slickgrids.toArray()[0] + let grid = this.slickgrids.toArray()[0]; + let self = this; if (grid) { let gridSelections = grid.getSelectedRanges(); - let viewport = ((grid as any)._grid.getCanvasNode() as HTMLElement).parentElement; - + let gridObject = grid as any; + let viewport = (gridObject._grid.getCanvasNode() as HTMLElement).parentElement; this.savedViewState = { gridSelections, scrollTop: viewport.scrollTop, scrollLeft: viewport.scrollLeft }; + + // Save the cell that is currently being edited. + // Note: This is only updating the data in tools service, not saving the change to database. + // This is added to fix the data inconsistency: the updated value is displayed but won't be saved to the database + // when committing the changes for the row. + if (this.currentCell.row !== undefined && this.currentCell.column !== undefined && this.currentCell.isEditable) { + gridObject._grid.getEditorLock().commitCurrentEdit(); + this.submitCurrentCellChange((result: EditUpdateCellResult) => { + self.setCellDirtyState(self.currentCell.row, self.currentCell.column, result.cell.isDirty); + }, (error: any) => { + self.notificationService.error(error); + }); + } } } @@ -614,6 +651,26 @@ export class EditDataComponent extends GridParentComponent implements OnInit, On viewport.scrollLeft = this.savedViewState.scrollLeft; viewport.scrollTop = this.savedViewState.scrollTop; this.savedViewState = undefined; + + // This block of code is responsible for restoring the dirty state indicators if slickgrid decides not to re-render the dirty row + // Other scenarios will be taken care of by getAdditionalCssClassesForCell method when slickgrid needs to re-render the rows. + if (this.currentCell.row !== undefined) { + if (this.isRowDirty(this.currentCell.row)) { + this.setRowDirtyState(this.currentCell.row, true); + + this.dirtyCells.forEach(cell => { + this.setCellDirtyState(this.currentCell.row, cell, true); + }); + } + } } } + + private isRowDirty(row: number): boolean { + return this.currentCell.row === row && this.dirtyCells.length > 0; + } + + private isCellDirty(row: number, column: number): boolean { + return this.currentCell.row === row && this.dirtyCells.indexOf(column) !== -1; + } } diff --git a/src/sql/parts/jobManagement/common/interfaces.ts b/src/sql/parts/jobManagement/common/interfaces.ts index f347da987e..2074d9454a 100644 --- a/src/sql/parts/jobManagement/common/interfaces.ts +++ b/src/sql/parts/jobManagement/common/interfaces.ts @@ -25,6 +25,8 @@ export interface IJobManagementService { getJobHistory(connectionUri: string, jobID: string): Thenable; deleteJob(connectionUri: string, job: sqlops.AgentJobInfo): Thenable; + deleteJobStep(connectionUri: string, step: sqlops.AgentJobStepInfo): Thenable; + getAlerts(connectionUri: string): Thenable; deleteAlert(connectionUri: string, alert: sqlops.AgentAlertInfo): Thenable; diff --git a/src/sql/parts/jobManagement/common/jobActions.ts b/src/sql/parts/jobManagement/common/jobActions.ts index ccb13a7bf8..ef48595e69 100644 --- a/src/sql/parts/jobManagement/common/jobActions.ts +++ b/src/sql/parts/jobManagement/common/jobActions.ts @@ -16,6 +16,7 @@ import { JobsViewComponent } from '../views/jobsView.component'; import { AlertsViewComponent } from 'sql/parts/jobManagement/views/alertsView.component'; import { OperatorsViewComponent } from 'sql/parts/jobManagement/views/operatorsView.component'; import { ProxiesViewComponent } from 'sql/parts/jobManagement/views/proxiesView.component'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; export enum JobActions { Run = 'run', @@ -25,6 +26,7 @@ export enum JobActions { export interface IJobActionInfo { ownerUri: string; targetObject: any; + jobHistoryComponent?: JobHistoryComponent; } // Job actions @@ -38,7 +40,7 @@ export class JobsRefreshAction extends Action { super(JobsRefreshAction.ID, JobsRefreshAction.LABEL, 'refreshIcon'); } - public run(context: JobsViewComponent): TPromise { + public run(context: JobsViewComponent | JobHistoryComponent): TPromise { return new TPromise((resolve, reject) => { if (context) { context.refreshJobs(); @@ -77,7 +79,8 @@ export class RunJobAction extends Action { constructor( @INotificationService private notificationService: INotificationService, - @IJobManagementService private jobManagementService: IJobManagementService + @IJobManagementService private jobManagementService: IJobManagementService, + @IInstantiationService private instantationService: IInstantiationService ) { super(RunJobAction.ID, RunJobAction.LABEL, 'runJobIcon'); } @@ -85,9 +88,11 @@ export class RunJobAction extends Action { public run(context: JobHistoryComponent): TPromise { let jobName = context.agentJobInfo.name; let ownerUri = context.ownerUri; + let refreshAction = this.instantationService.createInstance(JobsRefreshAction); return new TPromise((resolve, reject) => { this.jobManagementService.jobAction(ownerUri, jobName, JobActions.Run).then(result => { if (result.success) { + refreshAction.run(context); var startMsg = nls.localize('jobSuccessfullyStarted', ': The job was successfully started.'); this.notificationService.notify({ severity: Severity.Info, @@ -112,7 +117,8 @@ export class StopJobAction extends Action { constructor( @INotificationService private notificationService: INotificationService, - @IJobManagementService private jobManagementService: IJobManagementService + @IJobManagementService private jobManagementService: IJobManagementService, + @IInstantiationService private instantationService: IInstantiationService ) { super(StopJobAction.ID, StopJobAction.LABEL, 'stopJobIcon'); } @@ -120,14 +126,16 @@ export class StopJobAction extends Action { public run(context: JobHistoryComponent): TPromise { let jobName = context.agentJobInfo.name; let ownerUri = context.ownerUri; + let refreshAction = this.instantationService.createInstance(JobsRefreshAction); return new TPromise((resolve, reject) => { this.jobManagementService.jobAction(ownerUri, jobName, JobActions.Stop).then(result => { if (result.success) { - var stopMsg = nls.localize('jobSuccessfullyStopped', ': The job was successfully stopped.'); - this.notificationService.notify({ - severity: Severity.Info, - message: jobName+ stopMsg - }); + refreshAction.run(context); + var stopMsg = nls.localize('jobSuccessfullyStopped', ': The job was successfully stopped.'); + this.notificationService.notify({ + severity: Severity.Info, + message: jobName+ stopMsg + }); resolve(true); } else { this.notificationService.notify({ @@ -223,6 +231,48 @@ export class NewStepAction extends Action { } } +export class DeleteStepAction extends Action { + public static ID = 'jobaction.deleteStep'; + public static LABEL = nls.localize('jobaction.deleteStep', "Delete Step"); + + constructor( + @INotificationService private _notificationService: INotificationService, + @IJobManagementService private _jobService: IJobManagementService, + @IInstantiationService private instantationService: IInstantiationService + ) { + super(DeleteStepAction.ID, DeleteStepAction.LABEL); + } + + public run(actionInfo: IJobActionInfo): TPromise { + let self = this; + let step = actionInfo.targetObject as sqlops.AgentJobStepInfo; + let refreshAction = this.instantationService.createInstance(JobsRefreshAction); + self._notificationService.prompt( + Severity.Info, + nls.localize('jobaction.deleteStepConfirm,', "Are you sure you'd like to delete the step '{0}'?", step.stepName), + [{ + label: DeleteStepAction.LABEL, + run: () => { + self._jobService.deleteJobStep(actionInfo.ownerUri, actionInfo.targetObject).then(result => { + if (!result || !result.success) { + let errorMessage = nls.localize("jobaction.failedToDeleteStep", "Could not delete step '{0}'.\nError: {1}", + step.stepName, result.errorMessage ? result.errorMessage : 'Unknown error'); + self._notificationService.error(errorMessage); + } else { + refreshAction.run(actionInfo.jobHistoryComponent); + } + }); + } + }, { + label: DeleteAlertAction.CancelLabel, + run: () => { } + }] + ); + return TPromise.as(true); + } +} + + // Alert Actions export class NewAlertAction extends Action { diff --git a/src/sql/parts/jobManagement/common/jobManagementService.ts b/src/sql/parts/jobManagement/common/jobManagementService.ts index 401c487a5f..eaa4ceea2e 100644 --- a/src/sql/parts/jobManagement/common/jobManagementService.ts +++ b/src/sql/parts/jobManagement/common/jobManagementService.ts @@ -30,6 +30,7 @@ export class JobManagementService implements IJobManagementService { this._onDidChange.fire(void 0); } + // Jobs public getJobs(connectionUri: string): Thenable { return this._runAction(connectionUri, (runner) => { return runner.getJobs(connectionUri); @@ -42,6 +43,27 @@ export class JobManagementService implements IJobManagementService { }); } + public getJobHistory(connectionUri: string, jobID: string): Thenable { + return this._runAction(connectionUri, (runner) => { + return runner.getJobHistory(connectionUri, jobID); + }); + } + + public jobAction(connectionUri: string, jobName: string, action: string): Thenable { + return this._runAction(connectionUri, (runner) => { + return runner.jobAction(connectionUri, jobName, action); + }); + } + + // Steps + public deleteJobStep(connectionUri: string, stepInfo: sqlops.AgentJobStepInfo): Thenable { + return this._runAction(connectionUri, (runner) => { + return runner.deleteJobStep(connectionUri, stepInfo); + }); + } + + + // Alerts public getAlerts(connectionUri: string): Thenable { return this._runAction(connectionUri, (runner) => { return runner.getAlerts(connectionUri); @@ -54,6 +76,7 @@ export class JobManagementService implements IJobManagementService { }); } + // Operators public getOperators(connectionUri: string): Thenable { return this._runAction(connectionUri, (runner) => { return runner.getOperators(connectionUri); @@ -66,6 +89,7 @@ export class JobManagementService implements IJobManagementService { }); } + // Proxies public getProxies(connectionUri: string): Thenable { return this._runAction(connectionUri, (runner) => { return runner.getProxies(connectionUri); @@ -84,18 +108,6 @@ export class JobManagementService implements IJobManagementService { }); } - public getJobHistory(connectionUri: string, jobID: string): Thenable { - return this._runAction(connectionUri, (runner) => { - return runner.getJobHistory(connectionUri, jobID); - }); - } - - public jobAction(connectionUri: string, jobName: string, action: string): Thenable { - return this._runAction(connectionUri, (runner) => { - return runner.jobAction(connectionUri, jobName, action); - }); - } - private _runAction(uri: string, action: (handler: sqlops.AgentServicesProvider) => Thenable): Thenable { let providerId: string = this._connectionService.getProviderIdFromUri(uri); diff --git a/src/sql/parts/jobManagement/common/jobManagementUtilities.ts b/src/sql/parts/jobManagement/common/jobManagementUtilities.ts index febf1b4837..9b75625d20 100644 --- a/src/sql/parts/jobManagement/common/jobManagementUtilities.ts +++ b/src/sql/parts/jobManagement/common/jobManagementUtilities.ts @@ -17,7 +17,9 @@ export class JobManagementUtilities { switch(status) { case(0): return nls.localize('agentUtilities.failed','Failed'); case(1): return nls.localize('agentUtilities.succeeded', 'Succeeded'); - case(3): return nls.localize('agentUtilities.canceled', 'Canceled'); + case(2): return nls.localize('agentUtilities.retry', 'Retry'); + case(3): return nls.localize('agentUtilities.canceled', 'Cancelled'); + case(4): return nls.localize('agentUtilities.inProgress', 'In Progress'); case(5): return nls.localize('agentUtilities.statusUnknown', 'Status Unknown'); default: return nls.localize('agentUtilities.statusUnknown', 'Status Unknown'); } diff --git a/src/sql/parts/jobManagement/views/jobHistory.component.ts b/src/sql/parts/jobManagement/views/jobHistory.component.ts index 76148857ff..7fe8d1e5ab 100644 --- a/src/sql/parts/jobManagement/views/jobHistory.component.ts +++ b/src/sql/parts/jobManagement/views/jobHistory.component.ts @@ -74,7 +74,6 @@ export class JobHistoryComponent extends JobManagementView implements OnInit { @Inject(forwardRef(() => CommonServiceInterface)) commonService: CommonServiceInterface, @Inject(forwardRef(() => AgentViewComponent)) private _agentViewComponent: AgentViewComponent, @Inject(IWorkbenchThemeService) private themeService: IWorkbenchThemeService, - @Inject(INotificationService) private _notificationService: INotificationService, @Inject(IInstantiationService) private instantiationService: IInstantiationService, @Inject(IContextMenuService) private contextMenuService: IContextMenuService, @Inject(IJobManagementService) private _jobManagementService: IJobManagementService, @@ -185,8 +184,8 @@ export class JobHistoryComponent extends JobManagementView implements OnInit { stepViewRow.runStatus = jobStepStatus ? JobManagementUtilities.convertToStatusString(0) : JobManagementUtilities.convertToStatusString(step.runStatus); self._runStatus = JobManagementUtilities.convertToStatusString(self.agentJobHistoryInfo.runStatus); - stepViewRow.stepName = step.stepName; - stepViewRow.stepID = step.stepId.toString(); + stepViewRow.stepName = step.stepDetails.stepName; + stepViewRow.stepId = step.stepDetails.id.toString(); return stepViewRow; }); this._showSteps = self._stepRows.length > 0; @@ -315,6 +314,10 @@ export class JobHistoryComponent extends JobManagementView implements OnInit { } } + public refreshJobs() { + this._agentViewComponent.refresh = true; + } + protected initActionBar() { let runJobAction = this.instantiationService.createInstance(RunJobAction); let stopJobAction = this.instantiationService.createInstance(StopJobAction); @@ -353,4 +356,5 @@ export class JobHistoryComponent extends JobManagementView implements OnInit { this._showSteps = value; this._cd.detectChanges(); } + } diff --git a/src/sql/parts/jobManagement/views/jobStepsView.component.ts b/src/sql/parts/jobManagement/views/jobStepsView.component.ts index 01988379f3..7a92babbc0 100644 --- a/src/sql/parts/jobManagement/views/jobStepsView.component.ts +++ b/src/sql/parts/jobManagement/views/jobStepsView.component.ts @@ -20,7 +20,7 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { TabChild } from 'sql/base/browser/ui/panel/tab.component'; -import * as dom from 'vs/base/browser/dom'; +import { IJobManagementService } from 'sql/parts/jobManagement/common/interfaces'; export const JOBSTEPSVIEW_SELECTOR: string = 'jobstepsview-component'; @@ -36,7 +36,7 @@ export class JobStepsViewComponent extends JobManagementView implements OnInit, private _treeDataSource = new JobStepsViewDataSource(); private _treeRenderer = new JobStepsViewRenderer(); private _treeFilter = new JobStepsViewFilter(); - private static _pageSize = 1024; + private _pageSize = 1024; @ViewChild('table') private _tableContainer: ElementRef; @@ -66,7 +66,7 @@ export class JobStepsViewComponent extends JobManagementView implements OnInit, }, { verticalScrollMode: ScrollbarVisibility.Visible }); this._register(attachListStyler(this._tree, this.themeService)); } - this._tree.layout(JobStepsViewComponent._pageSize); + this._tree.layout(this._pageSize); this._tree.setInput(new JobStepsViewModel()); $('jobstepsview-component .steps-tree .monaco-tree').attr('tabIndex', '-1'); $('jobstepsview-component .steps-tree .monaco-tree-row').attr('tabIndex', '0'); @@ -74,8 +74,6 @@ export class JobStepsViewComponent extends JobManagementView implements OnInit, } ngOnInit() { - - let ownerUri: string = this._commonService.connectionManagementService.connectionInfo.ownerUri; this._tree = new Tree(this._tableContainer.nativeElement, { controller: this._treeController, dataSource: this._treeDataSource, diff --git a/src/sql/parts/jobManagement/views/jobStepsViewTree.ts b/src/sql/parts/jobManagement/views/jobStepsViewTree.ts index 9859f10611..ac03318b61 100644 --- a/src/sql/parts/jobManagement/views/jobStepsViewTree.ts +++ b/src/sql/parts/jobManagement/views/jobStepsViewTree.ts @@ -10,11 +10,10 @@ import * as TreeDefaults from 'vs/base/parts/tree/browser/treeDefaults'; import { Promise, TPromise } from 'vs/base/common/winjs.base'; import { IMouseEvent } from 'vs/base/browser/mouseEvent'; import { generateUuid } from 'vs/base/common/uuid'; -import { AgentJobHistoryInfo } from 'sqlops'; import { JobManagementUtilities } from 'sql/parts/jobManagement/common/jobManagementUtilities'; export class JobStepsViewRow { - public stepID: string; + public stepId: string; public stepName: string; public message: string; public rowID: string = generateUuid(); @@ -27,7 +26,6 @@ export class JobStepsViewModel { } export class JobStepsViewController extends TreeDefaults.DefaultController { - private _jobHistories: AgentJobHistoryInfo[]; protected onLeftClick(tree: tree.ITree, element: JobStepsViewRow, event: IMouseEvent, origin: string = 'mouse'): boolean { return true; @@ -37,14 +35,6 @@ export class JobStepsViewController extends TreeDefaults.DefaultController { return true; } - public set jobHistories(value: AgentJobHistoryInfo[]) { - this._jobHistories = value; - } - - public get jobHistories(): AgentJobHistoryInfo[] { - return this._jobHistories; - } - } export class JobStepsViewDataSource implements tree.IDataSource { @@ -121,7 +111,7 @@ export class JobStepsViewRenderer implements tree.IRenderer { public renderElement(tree: tree.ITree, element: JobStepsViewRow, templateId: string, templateData: IListTemplate): void { let stepIdCol: HTMLElement = DOM.$('div'); stepIdCol.className = 'tree-id-col'; - stepIdCol.innerText = element.stepID; + stepIdCol.innerText = element.stepId; let stepNameCol: HTMLElement = DOM.$('div'); stepNameCol.className = 'tree-name-col'; stepNameCol.innerText = element.stepName; diff --git a/src/sql/parts/jobManagement/views/jobsView.component.ts b/src/sql/parts/jobManagement/views/jobsView.component.ts index 5a5738efb1..a6646460ca 100644 --- a/src/sql/parts/jobManagement/views/jobsView.component.ts +++ b/src/sql/parts/jobManagement/views/jobsView.component.ts @@ -37,6 +37,7 @@ import { IDashboardService } from 'sql/services/dashboard/common/dashboardServic import { escape } from 'sql/base/common/strings'; import { IWorkbenchThemeService, IColorTheme } from 'vs/workbench/services/themes/common/workbenchThemeService'; import { tableBackground, cellBackground, tableHoverBackground, jobsHeadingBackground, cellBorderColor } from 'sql/common/theme/colors'; +import { JobStepsViewRow } from 'sql/parts/jobManagement/views/jobStepsViewTree'; export const JOBSVIEW_SELECTOR: string = 'jobsview-component'; export const ROW_HEIGHT: number = 45; @@ -171,7 +172,6 @@ export class JobsViewComponent extends JobManagementView implements OnInit { }); this.rowDetail = rowDetail; columns.unshift(this.rowDetail.getColumnDefinition()); - let filterPlugin = new HeaderFilter({}, this._themeService); this.filterPlugin = filterPlugin; $(this._gridEl.nativeElement).empty(); @@ -475,7 +475,7 @@ export class JobsViewComponent extends JobManagementView implements OnInit { case ('Failed'): resultIndicatorClass = 'jobview-jobnameindicatorfailure'; break; - case ('Canceled'): + case ('Cancelled'): resultIndicatorClass = 'jobview-jobnameindicatorcancel'; break; case ('Status Unknown'): @@ -523,17 +523,15 @@ export class JobsViewComponent extends JobManagementView implements OnInit { this.rowDetail.applyTemplateNewLineHeight(item, true); } - private loadJobHistories(): void { + private async loadJobHistories() { if (this.jobs) { - let erroredJobs = 0; let ownerUri: string = this._commonService.connectionManagementService.connectionInfo.ownerUri; let separatedJobs = this.separateFailingJobs(); // grab histories of the failing jobs first // so they can be expanded quicker let failing = separatedJobs[0]; - this.curateJobHistory(failing, ownerUri); let passing = separatedJobs[1]; - this.curateJobHistory(passing, ownerUri); + Promise.all([this.curateJobHistory(failing, ownerUri), this.curateJobHistory(passing, ownerUri)]); } } @@ -578,16 +576,20 @@ export class JobsViewComponent extends JobManagementView implements OnInit { return job; } - private curateJobHistory(jobs: sqlops.AgentJobInfo[], ownerUri: string) { + private async curateJobHistory(jobs: sqlops.AgentJobInfo[], ownerUri: string) { const self = this; - for (let i = 0; i < jobs.length; i++) { - let job = jobs[i]; - this._jobManagementService.getJobHistory(ownerUri, job.jobId).then((result) => { + jobs.forEach(async (job) => { + await this._jobManagementService.getJobHistory(ownerUri, job.jobId).then((result) => { if (result && result.jobs) { self.jobHistories[job.jobId] = result.jobs; self._jobCacheObject.setJobHistory(job.jobId, result.jobs); let jobHistories = self._jobCacheObject.getJobHistory(job.jobId); - let previousRuns = jobHistories.slice(jobHistories.length - 5, jobHistories.length); + let previousRuns: sqlops.AgentJobHistoryInfo[]; + if (jobHistories.length >= 5) { + previousRuns = jobHistories.slice(jobHistories.length - 5, jobHistories.length); + } else { + previousRuns = jobHistories; + } self.createJobChart(job.jobId, previousRuns); if (self._agentViewComponent.expanded.has(job.jobId)) { let lastJobHistory = jobHistories[result.jobs.length - 1]; @@ -600,7 +602,7 @@ export class JobsViewComponent extends JobManagementView implements OnInit { } } }); - } + }); } private createJobChart(jobId: string, jobHistories: sqlops.AgentJobHistoryInfo[]): void { @@ -648,12 +650,22 @@ export class JobsViewComponent extends JobManagementView implements OnInit { maxDuration = maxDuration === 0 ? 1 : maxDuration; let maxBarHeight: number = 24; let chartHeights = []; + let zeroDurationJobCount = 0; for (let i = 0; i < jobHistories.length; i++) { let duration = jobHistories[i].runDuration; let chartHeight = (maxBarHeight * JobManagementUtilities.convertDurationToSeconds(duration)) / maxDuration; chartHeights.push(`${chartHeight}px`); + if (chartHeight === 0) { + zeroDurationJobCount++; + } + } + // if the durations are all 0 secs, show minimal chart + // instead of nothing + if (zeroDurationJobCount === jobHistories.length) { + return ['5px', '5px', '5px', '5px', '5px']; + } else { + return chartHeights; } - return chartHeights; } private expandJobs(start: boolean): void { @@ -848,6 +860,40 @@ export class JobsViewComponent extends JobManagementView implements OnInit { return TPromise.as(actions); } + protected convertStepsToStepInfos(steps: sqlops.AgentJobStep[], job: sqlops.AgentJobInfo): sqlops.AgentJobStepInfo[] { + let result = []; + steps.forEach(step => { + let stepInfo: sqlops.AgentJobStepInfo = { + jobId: job.jobId, + jobName: job.name, + script: null, + scriptName: null, + stepName: step.stepName, + subSystem: null, + id: +step.stepId, + failureAction: null, + successAction: null, + failStepId: null, + successStepId: null, + command: null, + commandExecutionSuccessCode: null, + databaseName: null, + databaseUserName: null, + server: null, + outputFileName: null, + appendToLogFile: null, + appendToStepHist: null, + writeLogToTable: null, + appendLogToTable: null, + retryAttempts: null, + retryInterval: null, + proxyName: null + }; + result.push(stepInfo); + }); + return result; + } + protected getCurrentTableObject(rowIndex: number): any { let data = this._table.grid.getData(); if (!data || rowIndex >= data.getLength()) { @@ -855,10 +901,60 @@ export class JobsViewComponent extends JobManagementView implements OnInit { } let jobId = data.getItem(rowIndex).jobId; - let job = this.jobs.filter(job => { + if (!jobId) { + // if we couldn't find the ID, check if it's an + // error row + let isErrorRow: boolean = data.getItem(rowIndex).id.indexOf('error') >= 0; + if (isErrorRow) { + jobId = data.getItem(rowIndex - 1).jobId; + } + } + let job: sqlops.AgentJobInfo[] = this.jobs.filter(job => { return job.jobId === jobId; }); - + let jobHistories = this.jobHistories[jobId]; + let steps: sqlops.AgentJobStep[] = undefined; + let schedules: sqlops.AgentJobScheduleInfo[] = undefined; + let alerts: sqlops.AgentAlertInfo[] = undefined; + if (jobHistories && jobHistories[jobHistories.length-1]) { + // add steps + steps = jobHistories[jobHistories.length-1].steps; + if (steps && steps.length > 0) { + if (!job[0].JobSteps) { + job[0].JobSteps = []; + } + if (job[0].JobSteps.length !== steps.length) { + job[0].JobSteps = []; + steps.forEach(step => { + job[0].JobSteps.push(step.stepDetails); + }); + } + } + // add schedules + schedules = jobHistories[jobHistories.length-1].schedules; + if (schedules && schedules.length > 0) { + if (!job[0].JobSchedules) { + job[0].JobSchedules = []; + } + if (job[0].JobSchedules.length !== schedules.length) { + job[0].JobSchedules = []; + schedules.forEach(schedule => { + job[0].JobSchedules.push(schedule); + }); + } + } + // add alerts + alerts = jobHistories[jobHistories.length-1].alerts; + if (!job[0].Alerts) { + job[0].Alerts = []; + } + if (job[0].Alerts.length !== alerts.length) { + job[0].Alerts = []; + alerts.forEach(alert => { + job[0].Alerts.push(alert); + }); + } + } return job && job.length > 0 ? job[0] : undefined; } diff --git a/src/sql/parts/modelComponents/tree/tree.component.ts b/src/sql/parts/modelComponents/tree/tree.component.ts index 973a0cc866..421cfb8369 100644 --- a/src/sql/parts/modelComponents/tree/tree.component.ts +++ b/src/sql/parts/modelComponents/tree/tree.component.ts @@ -82,9 +82,10 @@ export default class TreeComponent extends ComponentBase implements IComponent, } public refreshDataProvider(itemsToRefreshByHandle: { [treeItemHandle: string]: ITreeComponentItem }): void { - // if (this._dataProvider) { - // const itemsToRefresh = this._dataProvider.getItemsToRefresh(itemsToRefreshByHandle); - // } + if (this._dataProvider) { + this._dataProvider.getItemsToRefresh(itemsToRefreshByHandle); + } + if (this._tree) { for (const item of Object.values(itemsToRefreshByHandle)) { this._tree.refresh(item); diff --git a/src/sql/parts/modelComponents/tree/treeComponentRenderer.ts b/src/sql/parts/modelComponents/tree/treeComponentRenderer.ts index 854bcba356..6923471116 100644 --- a/src/sql/parts/modelComponents/tree/treeComponentRenderer.ts +++ b/src/sql/parts/modelComponents/tree/treeComponentRenderer.ts @@ -142,7 +142,7 @@ export class TreeComponentRenderer extends Disposable implements IRenderer { if (element) { element.onCheckedChanged = (checked: boolean) => { this._dataProvider.onNodeCheckedChanged(element.handle, checked); - } + }; templateData.model = element; } if (templateId === TreeComponentRenderer.DEFAULT_TEMPLATE) { diff --git a/src/sql/parts/objectExplorer/common/objectExplorerService.ts b/src/sql/parts/objectExplorer/common/objectExplorerService.ts index 0dd0847354..8680e0b864 100644 --- a/src/sql/parts/objectExplorer/common/objectExplorerService.ts +++ b/src/sql/parts/objectExplorer/common/objectExplorerService.ts @@ -53,7 +53,7 @@ export interface IObjectExplorerService { updateObjectExplorerNodes(connectionProfile: IConnectionProfile): Promise; - deleteObjectExplorerNode(connection: IConnectionProfile): void; + deleteObjectExplorerNode(connection: IConnectionProfile): Thenable; onUpdateObjectExplorerNodes: Event; @@ -142,16 +142,17 @@ export class ObjectExplorerService implements IObjectExplorerService { }); } - public deleteObjectExplorerNode(connection: IConnectionProfile): void { + public deleteObjectExplorerNode(connection: IConnectionProfile): Thenable { let self = this; var connectionUri = connection.id; var nodeTree = this._activeObjectExplorerNodes[connectionUri]; if (nodeTree) { - self.closeSession(connection.providerName, nodeTree.getSession()).then(() => { + return self.closeSession(connection.providerName, nodeTree.getSession()).then(() => { delete self._activeObjectExplorerNodes[connectionUri]; delete self._sessions[nodeTree.getSession().sessionId]; }); } + return Promise.resolve(); } /** @@ -328,6 +329,21 @@ export class ObjectExplorerService implements IObjectExplorerService { } public closeSession(providerId: string, session: sqlops.ObjectExplorerSession): Thenable { + // Complete any requests that are still open for the session + let sessionStatus = this._sessions[session.sessionId]; + if (sessionStatus && sessionStatus.nodes) { + Object.entries(sessionStatus.nodes).forEach(([nodePath, nodeStatus]: [string, NodeStatus]) => { + if (nodeStatus.expandEmitter) { + nodeStatus.expandEmitter.fire({ + sessionId: session.sessionId, + nodes: [], + nodePath: nodePath, + errorMessage: undefined + }); + } + }); + } + let provider = this._providers[providerId]; if (provider) { return provider.closeSession({ @@ -350,7 +366,9 @@ export class ObjectExplorerService implements IObjectExplorerService { } public resolveTreeNodeChildren(session: sqlops.ObjectExplorerSession, parentTree: TreeNode): Thenable { - return this.expandOrRefreshTreeNode(session, parentTree); + // Always refresh the node if it has an error, otherwise expand it normally + let needsRefresh = !!parentTree.errorStateMessage; + return this.expandOrRefreshTreeNode(session, parentTree, needsRefresh); } public refreshTreeNode(session: sqlops.ObjectExplorerSession, parentTree: TreeNode): Thenable { diff --git a/src/sql/parts/objectExplorer/viewlet/serverTreeView.ts b/src/sql/parts/objectExplorer/viewlet/serverTreeView.ts index cbc77e8ce4..38c9dc49b5 100644 --- a/src/sql/parts/objectExplorer/viewlet/serverTreeView.ts +++ b/src/sql/parts/objectExplorer/viewlet/serverTreeView.ts @@ -231,9 +231,10 @@ export class ServerTreeView { if (connection) { var conn = this.getConnectionInTreeInput(connection.id); if (conn) { - this._objectExplorerService.deleteObjectExplorerNode(conn); - this._tree.collapse(conn); - this._tree.refresh(conn); + this._objectExplorerService.deleteObjectExplorerNode(conn).then(() => { + this._tree.collapse(conn); + this._tree.refresh(conn); + }); } } } diff --git a/src/sql/parts/objectExplorer/viewlet/treeUpdateUtils.ts b/src/sql/parts/objectExplorer/viewlet/treeUpdateUtils.ts index 7a7f54b18b..332ef93a07 100644 --- a/src/sql/parts/objectExplorer/viewlet/treeUpdateUtils.ts +++ b/src/sql/parts/objectExplorer/viewlet/treeUpdateUtils.ts @@ -130,14 +130,16 @@ export class TreeUpdateUtils { if (tree) { // Show the spinner in OE by adding the 'loading' trait to the connection, and set up callbacks to hide the spinner tree.addTraits('loading', [connection]); + let rejectOrCancelCallback = () => { + tree.collapse(connection); + tree.removeTraits('loading', [connection]); + }; callbacks = { onConnectStart: undefined, - onConnectReject: () => { - tree.collapse(connection); - tree.removeTraits('loading', [connection]); - }, + onConnectReject: rejectOrCancelCallback, onConnectSuccess: () => tree.removeTraits('loading', [connection]), - onDisconnect: undefined + onDisconnect: undefined, + onConnectCanceled: rejectOrCancelCallback, }; } connectionManagementService.connect(connection, undefined, options, callbacks).then(result => { diff --git a/src/sql/parts/query/common/queryInput.ts b/src/sql/parts/query/common/queryInput.ts index a095265580..50ee9f2b94 100644 --- a/src/sql/parts/query/common/queryInput.ts +++ b/src/sql/parts/query/common/queryInput.ts @@ -225,6 +225,9 @@ export class QueryInput extends EditorInput implements IEncodingSupport, IConnec this._updateTaskbar.fire(); } + public onConnectCanceled(): void { + } + public onConnectSuccess(params?: INewConnectionParams): void { this._runQueryEnabled = true; this._connectEnabled = false; @@ -246,6 +249,7 @@ export class QueryInput extends EditorInput implements IEncodingSupport, IConnec } } this._updateTaskbar.fire(); + this._onDidChangeLabel.fire(); } public onDisconnect(): void { diff --git a/src/sql/parts/query/editor/gridPanel.ts b/src/sql/parts/query/editor/gridPanel.ts index f083fccc6b..f6ba04b042 100644 --- a/src/sql/parts/query/editor/gridPanel.ts +++ b/src/sql/parts/query/editor/gridPanel.ts @@ -242,6 +242,7 @@ export class GridPanel extends ViewletPanel { } dispose(this.tables); this.tables = []; + this.maximizedGrid = undefined; this.maximumBodySize = this.tables.reduce((p, c) => { return p + c.maximumSize; @@ -552,12 +553,6 @@ class GridTable extends Disposable implements IView { private loadData(offset: number, count: number): Thenable { return this.runner.getQueryRows(offset, count, this.resultSet.batchId, this.resultSet.id).then(response => { - if (this.runner.isQueryPlan) { - // it's a show plan response - if (response.resultSubset.rowCount === 1) { - this.instantiationService.createInstance(ShowQueryPlanAction).run(response.resultSubset.rows[0][0].displayValue); - } - } return response.resultSubset.rows.map(r => { let dataWithSchema = {}; // skip the first column since its a number column diff --git a/src/sql/parts/query/editor/queryResultsView.ts b/src/sql/parts/query/editor/queryResultsView.ts index 942846cd20..67eb615482 100644 --- a/src/sql/parts/query/editor/queryResultsView.ts +++ b/src/sql/parts/query/editor/queryResultsView.ts @@ -203,6 +203,11 @@ export class QueryResultsView { if (!this._panelView.contains(this.qpTab)) { this._panelView.pushTab(this.qpTab); } + } else if (queryRunner.isQueryPlan) { + let disp = queryRunner.onResultSet(() => { + this.showPlan(queryRunner.planXml); + disp.dispose(); + }); } if (this.input.state.activeTab) { this._panelView.showTab(this.input.state.activeTab); diff --git a/src/sql/parts/query/execution/queryRunner.ts b/src/sql/parts/query/execution/queryRunner.ts index 2c6fc45c8e..ba1530b5a2 100644 --- a/src/sql/parts/query/execution/queryRunner.ts +++ b/src/sql/parts/query/execution/queryRunner.ts @@ -72,6 +72,8 @@ export default class QueryRunner { private _isQueryPlan: boolean; public get isQueryPlan(): boolean { return this._isQueryPlan; } + private _planXml: string; + public get planXml(): string { return this._planXml; } private _onMessage = new Emitter(); private _debouncedMessage = debounceEvent(this._onMessage.event, (l, e) => { @@ -338,6 +340,10 @@ export default class QueryRunner { } else { batchSet = this.batchSets[resultSet.batchId]; } + // handle getting queryPlanxml if we need too + if (this.isQueryPlan) { + this.getQueryRows(0, 1, 0, 0).then(e => this._planXml = e.resultSubset.rows[0][0].displayValue); + } if (batchSet) { // Store the result set in the batch and emit that a result set has completed batchSet.resultSetSummaries[resultSet.id] = resultSet; diff --git a/src/sql/parts/taskHistory/viewlet/taskHistoryRenderer.ts b/src/sql/parts/taskHistory/viewlet/taskHistoryRenderer.ts index ecd20d52e9..bbc47701fa 100644 --- a/src/sql/parts/taskHistory/viewlet/taskHistoryRenderer.ts +++ b/src/sql/parts/taskHistory/viewlet/taskHistoryRenderer.ts @@ -19,7 +19,7 @@ import * as Utils from 'sql/parts/connection/common/utils'; export class TaskHistoryRenderer implements IRenderer { public static readonly TASKOBJECT_HEIGHT = 65; - private static readonly ICON_CLASS = 'task-icon icon'; + private static readonly ICON_CLASS = 'task-icon sql icon'; private static readonly TASKOBJECT_TEMPLATE_ID = 'carbonTask'; private static readonly FAIL_CLASS = 'error'; private static readonly SUCCESS_CLASS = 'success'; diff --git a/src/sql/sqlops.d.ts b/src/sql/sqlops.d.ts index 2515752e79..a34e97b8dc 100644 --- a/src/sql/sqlops.d.ts +++ b/src/sql/sqlops.d.ts @@ -1313,6 +1313,7 @@ declare module 'sqlops' { jobCount: number; activeEndDate: string; scheduleUid: string; + description: string; } export interface AgentJobStep { @@ -1322,6 +1323,7 @@ declare module 'sqlops' { message: string; runDate: string; runStatus: number; + stepDetails: AgentJobStepInfo; } export interface AgentJobStepInfo { @@ -1369,6 +1371,8 @@ declare module 'sqlops' { retriesAttempted: string; server: string; steps: AgentJobStep[]; + schedules: AgentJobScheduleInfo[]; + alerts: AgentAlertInfo[]; } export interface AgentProxyInfo { @@ -1533,9 +1537,9 @@ declare module 'sqlops' { getJobDefaults(ownerUri: string): Thenable; // Job Step management methods - createJobStep(ownerUri: string, jobInfo: AgentJobStepInfo): Thenable; - updateJobStep(ownerUri: string, originalJobStepName: string, jobInfo: AgentJobStepInfo): Thenable; - deleteJobStep(ownerUri: string, jobInfo: AgentJobStepInfo): Thenable; + createJobStep(ownerUri: string, stepInfo: AgentJobStepInfo): Thenable; + updateJobStep(ownerUri: string, originalJobStepName: string, stepInfo: AgentJobStepInfo): Thenable; + deleteJobStep(ownerUri: string, stepInfo: AgentJobStepInfo): Thenable; // Alert management methods getAlerts(ownerUri: string): Thenable; diff --git a/src/sql/workbench/api/node/extHostDataProtocol.ts b/src/sql/workbench/api/node/extHostDataProtocol.ts index 95bd9d950a..0d430c383e 100644 --- a/src/sql/workbench/api/node/extHostDataProtocol.ts +++ b/src/sql/workbench/api/node/extHostDataProtocol.ts @@ -596,6 +596,13 @@ export class ExtHostDataProtocol extends ExtHostDataProtocolShape { throw this._resolveProvider(handle).deleteJob(ownerUri, job); } + /** + * Deletes a job step + */ + $deleteJobStep(handle: number, ownerUri: string, step: sqlops.AgentJobStepInfo): Thenable { + throw this._resolveProvider(handle).deleteJobStep(ownerUri, step); + } + /** * Get Agent Alerts list */ diff --git a/src/sql/workbench/api/node/mainThreadDataProtocol.ts b/src/sql/workbench/api/node/mainThreadDataProtocol.ts index ba8fa88f2e..bed01823a2 100644 --- a/src/sql/workbench/api/node/mainThreadDataProtocol.ts +++ b/src/sql/workbench/api/node/mainThreadDataProtocol.ts @@ -359,6 +359,9 @@ export class MainThreadDataProtocol implements MainThreadDataProtocolShape { deleteJob(connectionUri: string, jobInfo: sqlops.AgentJobInfo): Thenable { return self._proxy.$deleteJob(handle, connectionUri, jobInfo); }, + deleteJobStep(connectionUri: string, stepInfo: sqlops.AgentJobStepInfo): Thenable { + return self._proxy.$deleteJobStep(handle, connectionUri, stepInfo); + }, getAlerts(connectionUri: string): Thenable { return self._proxy.$getAlerts(handle, connectionUri); }, diff --git a/src/sql/workbench/api/node/sqlExtHost.protocol.ts b/src/sql/workbench/api/node/sqlExtHost.protocol.ts index 77ce5b268c..7b7d94bda3 100644 --- a/src/sql/workbench/api/node/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/node/sqlExtHost.protocol.ts @@ -370,6 +370,11 @@ export abstract class ExtHostDataProtocolShape { */ $deleteJob(handle: number, ownerUri: string, job: sqlops.AgentJobInfo): Thenable { throw ni(); } + /** + * Deletes a job step + */ + $deleteJobStep(handle: number, ownerUri: string, step: sqlops.AgentJobStepInfo): Thenable { throw ni(); } + /** * Get Agent Alerts list */ diff --git a/src/sqltest/parts/connection/connectionConfig.test.ts b/src/sqltest/parts/connection/connectionConfig.test.ts index 4f00287202..b190847e04 100644 --- a/src/sqltest/parts/connection/connectionConfig.test.ts +++ b/src/sqltest/parts/connection/connectionConfig.test.ts @@ -12,29 +12,88 @@ import { IConnectionProfile, IConnectionProfileStore } from 'sql/parts/connectio import { ConnectionProfile } from 'sql/parts/connection/common/connectionProfile'; import { ConfigurationTarget } from 'vs/platform/configuration/common/configuration'; import { WorkspaceConfigurationTestService } from 'sqltest/stubs/workspaceConfigurationTestService'; -import { IConfigurationValue as TConfigurationValue, ConfigurationEditingService } from 'vs/workbench/services/configuration/node/configurationEditingService'; +import { IConfigurationValue, ConfigurationEditingService } from 'vs/workbench/services/configuration/node/configurationEditingService'; import * as Constants from 'sql/parts/connection/common/constants'; import { IConnectionProfileGroup, ConnectionProfileGroup } from 'sql/parts/connection/common/connectionProfileGroup'; import { TPromise } from 'vs/base/common/winjs.base'; import * as assert from 'assert'; -import { CapabilitiesService } from 'sql/services/capabilities/capabilitiesService'; +import { ProviderFeatures, ICapabilitiesService } from 'sql/services/capabilities/capabilitiesService'; import * as sqlops from 'sqlops'; import { Emitter } from 'vs/base/common/event'; +import { ConnectionOptionSpecialType, ServiceOptionType } from 'sql/workbench/api/common/sqlExtHostTypes'; +import { StorageService, InMemoryLocalStorage } from 'vs/platform/storage/common/storageService'; +import { CapabilitiesTestService } from 'sqltest/stubs/capabilitiesTestService'; suite('SQL ConnectionConfig tests', () => { - /* - let capabilitiesService: TypeMoq.Mock; - let workspaceConfigurationServiceMock: TypeMoq.Mock; - let configEditingServiceMock: TypeMoq.Mock; - let msSQLCapabilities: sqlops.DataProtocolServerCapabilities; - let capabilities: sqlops.DataProtocolServerCapabilities[]; - let onProviderRegistered = new Emitter(); - - let configValueToConcat: TConfigurationValue = { - workspace: [{ + let capabilitiesService: TypeMoq.Mock; + let workspaceConfigurationServiceMock: TypeMoq.Mock; + let configEditingServiceMock: TypeMoq.Mock; + let msSQLCapabilities: ProviderFeatures; + let capabilities: ProviderFeatures[]; + let onCapabilitiesRegistered = new Emitter(); + + let configValueToConcat = { + workspace: [{ + name: 'g1', + id: 'g1', + parentId: 'ROOT', + color: 'pink', + description: 'g1' + }, + { + name: 'g1-1', + id: 'g1-1', + parentId: 'g1', + color: 'blue', + description: 'g1-1' + } + ], + user: [{ + name: 'ROOT', + id: 'ROOT', + parentId: '', + color: 'white', + description: 'ROOT' + }, { + name: 'g2', + id: 'g2', + parentId: 'ROOT', + color: 'green', + description: 'g2' + }, + { + name: 'g2-1', + id: 'g2-1', + parentId: 'g2', + color: 'yellow', + description: 'g2' + }, + { + name: 'g3', + id: 'g3', + parentId: '', + color: 'orange', + description: 'g3' + }, + { + name: 'g3-1', + id: 'g3-1', + parentId: 'g3', + color: 'purple', + description: 'g3-1' + } + ], + value: [], + default: [], + workspaceFolder: [] + }; + + let configValueToMerge = { + workspace: [ + { name: 'g1', id: 'g1', - parentId: 'ROOT', + parentId: '', color: 'pink', description: 'g1' }, @@ -45,17 +104,12 @@ suite('SQL ConnectionConfig tests', () => { color: 'blue', description: 'g1-1' } - ], - user: [{ - name: 'ROOT', - id: 'ROOT', - parentId: '', - color: 'white', - description: 'ROOT' - }, { + ], + user: [ + { name: 'g2', id: 'g2', - parentId: 'ROOT', + parentId: '', color: 'green', description: 'g2' }, @@ -67,871 +121,838 @@ suite('SQL ConnectionConfig tests', () => { description: 'g2' }, { - name: 'g3', - id: 'g3', + name: 'g1', + id: 'g1', parentId: '', - color: 'orange', - description: 'g3' + color: 'pink', + description: 'g1' }, { - name: 'g3-1', - id: 'g3-1', - parentId: 'g3', - color: 'purple', - description: 'g3-1' - } - ], - value: [], - default: [], - folder: [] - }; - - let configValueToMerge: TConfigurationValue = { - workspace: [ - { - name: 'g1', - id: 'g1', - parentId: '', - color: 'pink', - description: 'g1' - }, - { - name: 'g1-1', - id: 'g1-1', - parentId: 'g1', - color: 'blue', - description: 'g1-1' - } - ], - user: [ - { - name: 'g2', - id: 'g2', - parentId: '', - color: 'green', - description: 'g2' - }, - { - name: 'g2-1', - id: 'g2-1', - parentId: 'g2', - color: 'yellow', - description: 'g2' - }, - { - name: 'g1', - id: 'g1', - parentId: '', - color: 'pink', - description: 'g1' - }, - { - name: 'g1-2', - id: 'g1-2', - parentId: 'g1', - color: 'silver', - description: 'g1-2' - }], - value: [], - default: [], - folder: [] - }; - - let connections: TConfigurationValue = { - workspace: [{ - options: { - serverName: 'server1', - databaseName: 'database', - userName: 'user', - password: 'password', - authenticationType: '' - }, - providerName: 'MSSQL', - groupId: 'test', - savePassword: true, - id: 'server1' - } - - - ], - user: [{ - options: { - serverName: 'server2', - databaseName: 'database', - userName: 'user', - password: 'password', - authenticationType: '' - }, - providerName: 'MSSQL', - groupId: 'test', - savePassword: true, - id: 'server2' - }, { - options: { - serverName: 'server3', - databaseName: 'database', - userName: 'user', - password: 'password', - authenticationType: '' - }, - providerName: 'MSSQL', - groupId: 'g3', - savePassword: true, - id: 'server3' - } - ], - value: [], - default: [], - folder: [] - }; - setup(() => { - capabilitiesService = TypeMoq.Mock.ofType(CapabilitiesService); - capabilities = []; - let connectionProvider: sqlops.ConnectionProviderOptions = { - options: [ - { - name: 'serverName', - displayName: undefined, - description: undefined, - groupName: undefined, - categoryValues: undefined, - defaultValue: undefined, - isIdentity: true, - isRequired: true, - specialValueType: 0, - valueType: 0 - }, - { - name: 'databaseName', - displayName: undefined, - description: undefined, - groupName: undefined, - categoryValues: undefined, - defaultValue: undefined, - isIdentity: true, - isRequired: true, - specialValueType: 1, - valueType: 0 - }, - { - name: 'userName', - displayName: undefined, - description: undefined, - groupName: undefined, - categoryValues: undefined, - defaultValue: undefined, - isIdentity: true, - isRequired: true, - specialValueType: 3, - valueType: 0 - }, - { - name: 'authenticationType', - displayName: undefined, - description: undefined, - groupName: undefined, - categoryValues: undefined, - defaultValue: undefined, - isIdentity: true, - isRequired: true, - specialValueType: 2, - valueType: 0 - }, - { - name: 'password', - displayName: undefined, - description: undefined, - groupName: undefined, - categoryValues: undefined, - defaultValue: undefined, - isIdentity: true, - isRequired: true, - specialValueType: 4, - valueType: 0 - } - ] - }; - msSQLCapabilities = { - protocolVersion: '1', - providerName: 'MSSQL', - providerDisplayName: 'MSSQL', - connectionProvider: connectionProvider, - adminServicesProvider: undefined, - features: undefined - }; - capabilities.push(msSQLCapabilities); - - capabilitiesService.setup(x => x.getCapabilities()).returns(() => capabilities); - capabilitiesService.setup(x => x.onProviderRegisteredEvent).returns(() => onProviderRegistered.event); - - workspaceConfigurationServiceMock = TypeMoq.Mock.ofType(WorkspaceConfigurationTestService); - workspaceConfigurationServiceMock.setup(x => x.reloadConfiguration()) - .returns(() => TPromise.as<{}>({})); - - configEditingServiceMock = TypeMoq.Mock.ofType(ConfigurationEditingService); - let nothing: void; - configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.USER, TypeMoq.It.isAny())).returns(() => TPromise.as(nothing)); - configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.WORKSPACE, TypeMoq.It.isAny())).returns(() => TPromise.as(nothing)); - }); - - function groupsAreEqual(groups1: IConnectionProfileGroup[], groups2: IConnectionProfileGroup[]): Boolean { - if (!groups1 && !groups2) { - return true; - } else if ((!groups1 && groups2 && groups2.length === 0) || (!groups2 && groups1 && groups1.length === 0)) { - return true; - } - - if (groups1.length !== groups2.length) { - return false; - } - - let areEqual = true; - - groups1.map(g1 => { - if (areEqual) { - let g2 = groups2.find(g => g.name === g1.name); - if (!g2) { - areEqual = false; - } else { - let result = groupsAreEqual(groups1.filter(a => a.parentId === g1.id), groups2.filter(b => b.parentId === g2.id)); - if (result === false) { - areEqual = false; - } - } - } - }); - - return areEqual; + name: 'g1-2', + id: 'g1-2', + parentId: 'g1', + color: 'silver', + description: 'g1-2' + }], + value: [], + default: [], + workspaceFolder: [] + }; + + let connections = { + workspace: [{ + options: { + serverName: 'server1', + databaseName: 'database', + userName: 'user', + password: 'password', + authenticationType: '' + }, + providerName: 'MSSQL', + groupId: 'test', + savePassword: true, + id: 'server1' } - - test('allGroups should return groups from user and workspace settings', () => { - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionGroupsArrayName)) - .returns(() => configValueToConcat); - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - let allGroups = config.getAllGroups(); - - - assert.notEqual(allGroups, undefined); - assert.equal(allGroups.length, configValueToConcat.workspace.length + configValueToConcat.user.length); - }); - - test('allGroups should merge groups from user and workspace settings', () => { - let expectedAllGroups: IConnectionProfileGroup[] = [ - { - name: 'g1', - id: 'g1', - parentId: '', - color: 'pink', - description: 'g1' - }, - { - name: 'g1-1', - id: 'g1-1', - parentId: 'g1', - color: 'blue', - description: 'g1-1' - }, - { - name: 'g2', - id: 'g2', - parentId: '', - color: 'yellow', - description: 'g2' - }, - { - name: 'g2-1', - id: 'g2-1', - parentId: 'g2', - color: 'red', - description: 'g2' - }, - { - name: 'g1-2', - id: 'g1-2', - parentId: 'g1', - color: 'green', - description: 'g1-2' - }]; - - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionGroupsArrayName)) - .returns(() => configValueToMerge); - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - let allGroups = config.getAllGroups(); - - - assert.notEqual(allGroups, undefined); - assert.equal(groupsAreEqual(allGroups, expectedAllGroups), true); - }); - - test('addConnection should add the new profile to user settings if does not exist', done => { - let newProfile: IConnectionProfile = { - serverName: 'new server', + + + ], + user: [{ + options: { + serverName: 'server2', databaseName: 'database', userName: 'user', password: 'password', - authenticationType: '', - savePassword: true, - groupFullName: undefined, - groupId: undefined, - getOptionsKey: undefined, - matches: undefined, - providerName: 'MSSQL', - options: {}, - saveProfile: true, - id: undefined - }; - - let expectedNumberOfConnections = connections.user.length + 1; - - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => connections); - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionGroupsArrayName)) - .returns(() => configValueToConcat); - - let connectionProfile = new ConnectionProfile(msSQLCapabilities, newProfile); - connectionProfile.options['databaseDisplayName'] = 'database'; - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - config.addConnection(connectionProfile).then(savedConnectionProfile => { - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); - assert.notEqual(savedConnectionProfile.id, undefined); - done(); - }).catch(error => { - assert.fail(); - done(); - }); - }); - - test('addConnection should not add the new profile to user settings if already exists', done => { - let profileFromConfig = connections.user[0]; - let newProfile: IConnectionProfile = { - serverName: profileFromConfig.options['serverName'], - databaseName: profileFromConfig.options['databaseName'], - userName: profileFromConfig.options['userName'], - password: profileFromConfig.options['password'], - authenticationType: profileFromConfig.options['authenticationType'], - groupId: profileFromConfig.groupId, - savePassword: true, - groupFullName: undefined, - getOptionsKey: undefined, - matches: undefined, - providerName: 'MSSQL', - options: {}, - saveProfile: true, - id: undefined - }; - - let expectedNumberOfConnections = connections.user.length; - - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => connections); - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionGroupsArrayName)) - .returns(() => configValueToConcat); - - let connectionProfile = new ConnectionProfile(msSQLCapabilities, newProfile); - connectionProfile.options['databaseDisplayName'] = profileFromConfig.options['databaseName']; - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - config.addConnection(connectionProfile).then(savedConnectionProfile => { - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); - assert.equal(savedConnectionProfile.id, profileFromConfig.id); - done(); - }).catch(error => { - assert.fail(); - done(); - }); - }); - - test('addConnection should add the new group to user settings if does not exist', done => { - let newProfile: IConnectionProfile = { - serverName: 'new server', + authenticationType: '' + }, + providerName: 'MSSQL', + groupId: 'test', + savePassword: true, + id: 'server2' + }, { + options: { + serverName: 'server3', databaseName: 'database', userName: 'user', password: 'password', - authenticationType: '', - savePassword: true, - groupFullName: 'g2/g2-2', - groupId: undefined, - getOptionsKey: undefined, - matches: undefined, - providerName: 'MSSQL', - options: {}, - saveProfile: true, - id: undefined - }; - - let expectedNumberOfConnections = connections.user.length + 1; - let expectedNumberOfGroups = configValueToConcat.user.length + 1; - - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => connections); - - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionGroupsArrayName)) - .returns(() => configValueToConcat); - - let connectionProfile = new ConnectionProfile(msSQLCapabilities, newProfile); - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - config.addConnection(connectionProfile).then(success => { - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileGroup[]).length === expectedNumberOfGroups)), TypeMoq.Times.once()); - done(); - }).catch(error => { - assert.fail(); - done(); - }); - }); - - test('getConnections should return connections from user and workspace settings given getWorkspaceConnections set to true', () => { - let getWorkspaceConnections: boolean = true; - - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => connections); - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - let allConnections = config.getConnections(getWorkspaceConnections); - assert.equal(allConnections.length, connections.user.length + connections.workspace.length); - }); - - test('getConnections should return connections from user settings given getWorkspaceConnections set to false', () => { - let getWorkspaceConnections: boolean = false; - - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => connections); - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - let allConnections = config.getConnections(getWorkspaceConnections); - assert.equal(allConnections.length, connections.user.length); - }); - - test('getConnections should return connections with a valid id', () => { - let getWorkspaceConnections: boolean = false; - let connectionsWithNoId: TConfigurationValue = { - user: connections.user.map(c => { - c.id = undefined; - return c; - }), - default: connections.default, - workspace: connections.workspace.map(c => { - c.id = c.options['serverName']; - return c; - }), - value: connections.value, - folder: [] - }; - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => connectionsWithNoId); - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - let allConnections = config.getConnections(getWorkspaceConnections); - assert.equal(allConnections.length, connections.user.length); - allConnections.forEach(connection => { - let userConnection = connectionsWithNoId.user.find(u => u.options['serverName'] === connection.serverName); - if (userConnection !== undefined) { - assert.notEqual(connection.id, connection.getOptionsKey()); - assert.notEqual(connection.id, undefined); - } else { - let workspaceConnection = connectionsWithNoId.workspace.find(u => u.options['serverName'] === connection.serverName); - assert.notEqual(connection.id, connection.getOptionsKey()); - assert.equal(workspaceConnection.id, connection.id); + authenticationType: '' + }, + providerName: 'MSSQL', + groupId: 'g3', + savePassword: true, + id: 'server3' + } + ], + value: [], + default: [], + workspaceFolder: [] + }; + setup(() => { + capabilitiesService = TypeMoq.Mock.ofType(CapabilitiesTestService); + capabilities = []; + let connectionProvider: sqlops.ConnectionProviderOptions = { + options: [ + { + name: 'serverName', + displayName: undefined, + description: undefined, + groupName: undefined, + categoryValues: undefined, + defaultValue: undefined, + isIdentity: true, + isRequired: true, + specialValueType: ConnectionOptionSpecialType.serverName, + valueType: ServiceOptionType.string + }, + { + name: 'databaseName', + displayName: undefined, + description: undefined, + groupName: undefined, + categoryValues: undefined, + defaultValue: undefined, + isIdentity: true, + isRequired: true, + specialValueType: ConnectionOptionSpecialType.databaseName, + valueType: ServiceOptionType.string + }, + { + name: 'userName', + displayName: undefined, + description: undefined, + groupName: undefined, + categoryValues: undefined, + defaultValue: undefined, + isIdentity: true, + isRequired: true, + specialValueType: ConnectionOptionSpecialType.userName, + valueType: ServiceOptionType.string + }, + { + name: 'authenticationType', + displayName: undefined, + description: undefined, + groupName: undefined, + categoryValues: undefined, + defaultValue: undefined, + isIdentity: true, + isRequired: true, + specialValueType: ConnectionOptionSpecialType.authType, + valueType: ServiceOptionType.string + }, + { + name: 'password', + displayName: undefined, + description: undefined, + groupName: undefined, + categoryValues: undefined, + defaultValue: undefined, + isIdentity: true, + isRequired: true, + specialValueType: ConnectionOptionSpecialType.password, + valueType: ServiceOptionType.string + } + ] + }; + msSQLCapabilities = { + connection: { + providerId: 'MSSQL', + displayName: 'MSSQL', + connectionOptions: connectionProvider.options + } + }; + capabilities.push(msSQLCapabilities); + + capabilitiesService.setup(x => x.getCapabilities('MSSQL')).returns(() => msSQLCapabilities); + (capabilitiesService.object as any).onCapabilitiesRegistered = onCapabilitiesRegistered.event; + + workspaceConfigurationServiceMock = TypeMoq.Mock.ofType(WorkspaceConfigurationTestService); + workspaceConfigurationServiceMock.setup(x => x.reloadConfiguration()) + .returns(() => TPromise.as(null)); + + configEditingServiceMock = TypeMoq.Mock.ofType(ConfigurationEditingService); + let nothing: void; + configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.USER, TypeMoq.It.isAny())).returns(() => TPromise.as(nothing)); + configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.WORKSPACE, TypeMoq.It.isAny())).returns(() => TPromise.as(nothing)); + }); + + function groupsAreEqual(groups1: IConnectionProfileGroup[], groups2: IConnectionProfileGroup[]): Boolean { + if (!groups1 && !groups2) { + return true; + } else if ((!groups1 && groups2 && groups2.length === 0) || (!groups2 && groups1 && groups1.length === 0)) { + return true; + } + + if (groups1.length !== groups2.length) { + return false; + } + + let areEqual = true; + + groups1.map(g1 => { + if (areEqual) { + let g2 = groups2.find(g => g.name === g1.name); + if (!g2) { + areEqual = false; + } else { + let result = groupsAreEqual(groups1.filter(a => a.parentId === g1.id), groups2.filter(b => b.parentId === g2.id)); + if (result === false) { + areEqual = false; + } } - }); - }); - - test('getConnections update the capabilities in each profile when the provider capabilities is registered', () => { - let oldOptionName: string = 'oldOptionName'; - let optionsMetadataFromConfig = capabilities[0].connectionProvider.options.concat({ - name: oldOptionName, - displayName: undefined, - description: undefined, - groupName: undefined, - categoryValues: undefined, - defaultValue: undefined, - isIdentity: true, - isRequired: true, - specialValueType: 0, - valueType: 0 - }); - - let capabilitiesFromConfig: sqlops.DataProtocolServerCapabilities[] = []; - let connectionProvider: sqlops.ConnectionProviderOptions = { - options: optionsMetadataFromConfig - }; - let msSQLCapabilities2 = { - protocolVersion: '1', - providerName: 'MSSQL', - providerDisplayName: 'MSSQL', - connectionProvider: connectionProvider, - adminServicesProvider: undefined, - features: undefined - }; - capabilitiesFromConfig.push(msSQLCapabilities2); - let connectionUsingOldMetadata = connections.user.map(c => { - c.options[oldOptionName] = 'oldOptionValue'; - return c; - }); - let configValue = Object.assign({}, connections, { user: connectionUsingOldMetadata }); - let capabilitiesService2: TypeMoq.Mock = TypeMoq.Mock.ofType(CapabilitiesService); - capabilitiesService2.setup(x => x.getCapabilities()).returns(() => []); - capabilitiesService2.setup(x => x.onProviderRegisteredEvent).returns(() => onProviderRegistered.event); - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => configValue); - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService2.object, capabilitiesFromConfig); - let allConnections = config.getConnections(false); - allConnections.forEach(element => { - assert.notEqual(element.serverName, undefined); - assert.notEqual(element.getOptionsKey().indexOf('oldOptionValue|'), -1); - }); - - onProviderRegistered.fire(msSQLCapabilities); - allConnections.forEach(element => { - assert.notEqual(element.serverName, undefined); - assert.equal(element.getOptionsKey().indexOf('oldOptionValue|'), -1); - }); - }); - - test('saveGroup should save the new groups to tree and return the id of the last group name', () => { - let config = new ConnectionConfig(undefined, undefined, undefined, undefined); - let groups: IConnectionProfileGroup[] = configValueToConcat.user; - let expectedLength = configValueToConcat.user.length + 2; - let newGroups: string = 'ROOT/g1/g1-1'; - let color: string = 'red'; - - let result: ISaveGroupResult = config.saveGroup(groups, newGroups, color, newGroups); - assert.notEqual(result, undefined); - assert.equal(result.groups.length, expectedLength, 'The result groups length is invalid'); - let newGroup = result.groups.find(g => g.name === 'g1-1'); - assert.equal(result.newGroupId, newGroup.id, 'The groups id is invalid'); - }); - - test('saveGroup should only add the groups that are not in the tree', () => { - let config = new ConnectionConfig(undefined, undefined, undefined, undefined); - let groups: IConnectionProfileGroup[] = configValueToConcat.user; - let expectedLength = configValueToConcat.user.length + 1; - let newGroups: string = 'ROOT/g2/g2-5'; - let color: string = 'red'; - - let result: ISaveGroupResult = config.saveGroup(groups, newGroups, color, newGroups); - assert.notEqual(result, undefined); - assert.equal(result.groups.length, expectedLength, 'The result groups length is invalid'); - let newGroup = result.groups.find(g => g.name === 'g2-5'); - assert.equal(result.newGroupId, newGroup.id, 'The groups id is invalid'); - }); - - test('saveGroup should not add any new group if tree already has all the groups in the full path', () => { - let config = new ConnectionConfig(undefined, undefined, undefined, undefined); - let groups: IConnectionProfileGroup[] = configValueToConcat.user; - let expectedLength = configValueToConcat.user.length; - let newGroups: string = 'ROOT/g2/g2-1'; - let color: string = 'red'; - - let result: ISaveGroupResult = config.saveGroup(groups, newGroups, color, newGroups); - assert.notEqual(result, undefined); - assert.equal(result.groups.length, expectedLength, 'The result groups length is invalid'); - let newGroup = result.groups.find(g => g.name === 'g2-1'); - assert.equal(result.newGroupId, newGroup.id, 'The groups id is invalid'); - }); - - test('deleteConnection should remove the connection from config', done => { - let newProfile: IConnectionProfile = { - serverName: 'server3', - databaseName: 'database', - userName: 'user', - password: 'password', - authenticationType: '', - savePassword: true, - groupFullName: 'g3', - groupId: 'g3', - getOptionsKey: undefined, - matches: undefined, - providerName: 'MSSQL', - options: {}, - saveProfile: true, - id: undefined - }; - - let expectedNumberOfConnections = connections.user.length - 1; - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => connections); - - let connectionProfile = new ConnectionProfile(msSQLCapabilities, newProfile); - connectionProfile.options['databaseDisplayName'] = 'database'; - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - config.deleteConnection(connectionProfile).then(() => { - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); - done(); - }).catch(error => { - assert.fail(); - done(); - }); - }); - - test('deleteConnectionGroup should remove the children connections and subgroups from config', done => { - let newProfile: IConnectionProfile = { - serverName: 'server3', - databaseName: 'database', - userName: 'user', - password: 'password', - authenticationType: '', - savePassword: true, - groupFullName: 'g3', - groupId: 'g3', - getOptionsKey: undefined, - matches: undefined, - providerName: 'MSSQL', - options: {}, - saveProfile: true, - id: undefined - }; - let connectionProfile = new ConnectionProfile(msSQLCapabilities, newProfile); - connectionProfile.options['databaseDisplayName'] = 'database'; - - let connectionProfileGroup = new ConnectionProfileGroup('g3', undefined, 'g3', undefined, undefined); - let childGroup = new ConnectionProfileGroup('g3-1', connectionProfileGroup, 'g3-1', undefined, undefined); - connectionProfileGroup.addGroups([childGroup]); - connectionProfileGroup.addConnections([connectionProfile]); - - let expectedNumberOfConnections = connections.user.length - 1; - let expectedNumberOfGroups = configValueToConcat.user.length - 2; - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => connections); - - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionGroupsArrayName)) - .returns(() => configValueToConcat); - - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - config.deleteGroup(connectionProfileGroup).then(() => { - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileGroup[]).length === expectedNumberOfGroups)), TypeMoq.Times.once()); - done(); - }).catch(error => { - assert.fail(); - done(); - }); - }); - - test('deleteConnection should not throw error for connection not in config', done => { - let newProfile: IConnectionProfile = { - serverName: 'server3', - databaseName: 'database', - userName: 'user', - password: 'password', - authenticationType: '', - savePassword: true, - groupFullName: 'g3', - groupId: 'newid', - getOptionsKey: undefined, - matches: undefined, - providerName: 'MSSQL', - options: {}, - saveProfile: true, - id: undefined - }; - - let expectedNumberOfConnections = connections.user.length; - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => connections); - - let connectionProfile = new ConnectionProfile(msSQLCapabilities, newProfile); - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - config.deleteConnection(connectionProfile).then(() => { - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); - done(); - }).catch(error => { - assert.fail(); - done(); - }); - }); - - test('renameGroup should change group name', done => { - - let expectedNumberOfConnections = configValueToConcat.user.length; - let calledValue: any; - let called: boolean = false; - let nothing: void; - let configEditingServiceMock: TypeMoq.Mock = TypeMoq.Mock.ofType(ConfigurationEditingService); - configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.USER, TypeMoq.It.isAny())).callback((x: any, val: any) => { - calledValue = val.value as IConnectionProfileStore[]; - }).returns(() => TPromise.as(nothing)); - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionGroupsArrayName)) - .returns(() => configValueToConcat); - - let connectionProfileGroup = new ConnectionProfileGroup('g-renamed', undefined, 'g2', undefined, undefined); - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - config.editGroup(connectionProfileGroup).then(() => { - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); - calledValue.forEach(con => { - if (con.id === 'g2') { - assert.equal(con.name, 'g-renamed', 'Group was not renamed'); - called = true; - } - }); - assert.equal(called, true, 'group was not renamed'); - }).then(() => done(), (err) => done(err)); - }); - - - test('change group(parent) for connection group', done => { - let expectedNumberOfConnections = configValueToConcat.user.length; - let calledValue: any; - let nothing: void; - let configEditingServiceMock: TypeMoq.Mock = TypeMoq.Mock.ofType(ConfigurationEditingService); - configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.USER, TypeMoq.It.isAny())).callback((x: any, val: any) => { - calledValue = val.value as IConnectionProfileStore[]; - }).returns(() => TPromise.as(nothing)); - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionGroupsArrayName)) - .returns(() => configValueToConcat); - - let sourceProfileGroup = new ConnectionProfileGroup('g2', undefined, 'g2', undefined, undefined); - let targetProfileGroup = new ConnectionProfileGroup('g3', undefined, 'g3', undefined, undefined); - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - config.changeGroupIdForConnectionGroup(sourceProfileGroup, targetProfileGroup).then(() => { - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); - calledValue.forEach(con => { - if (con.id === 'g2') { - assert.equal(con.parentId, 'g3', 'Group parent was not changed'); - } - }); - }).then(() => done(), (err) => done(err)); - }); - - - test('change group(parent) for connection', done => { - let newProfile: IConnectionProfile = { - serverName: 'server3', - databaseName: 'database', - userName: 'user', - password: 'password', - authenticationType: '', - savePassword: true, - groupFullName: 'g3', - groupId: 'g3', - getOptionsKey: () => { return 'connectionId'; }, - matches: undefined, - providerName: 'MSSQL', - options: {}, - saveProfile: true, - id: 'test' - }; - - let expectedNumberOfConnections = connections.user.length; - workspaceConfigurationServiceMock.setup(x => x.lookup( - Constants.connectionsArrayName)) - .returns(() => connections); - - let connectionProfile = new ConnectionProfile(msSQLCapabilities, newProfile); - let newId = 'newid'; - let calledValue: any; - let nothing: void; - let configEditingServiceMock: TypeMoq.Mock = TypeMoq.Mock.ofType(ConfigurationEditingService); - configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.USER, TypeMoq.It.isAny())).callback((x: any, val: any) => { - calledValue = val.value as IConnectionProfileStore[]; - }).returns(() => TPromise.as(nothing)); - configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.WORKSPACE, TypeMoq.It.isAny())).callback((x: any, val: any) => { - - }).returns(() => TPromise.as(nothing)); - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - config.changeGroupIdForConnection(connectionProfile, newId).then(() => { - configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, - TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.atLeastOnce()); - calledValue.forEach(con => { - }); - }).then(() => done(), (err) => done(err)); - }); - - test('fixConnectionIds should replace duplicate ids with new ones', (done) => { - let profiles: IConnectionProfileStore[] = [ - { - options: {}, - groupId: '1', - id: '1', - providerName: undefined, - savePassword: true, - }, { - options: {}, - groupId: '1', - id: '2', - providerName: undefined, - savePassword: true, - }, { - options: {}, - groupId: '1', - id: '3', - providerName: undefined, - savePassword: true, - }, { - options: {}, - groupId: '1', - id: '2', - providerName: undefined, - savePassword: true, - }, { - options: {}, - groupId: '1', - id: '4', - providerName: undefined, - savePassword: true, - }, { - options: {}, - groupId: '1', - id: '3', - providerName: undefined, - savePassword: true, - }, { - options: {}, - groupId: '1', - id: '2', - providerName: undefined, - savePassword: true, - } - ]; - - let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); - config.fixConnectionIds(profiles); - let ids = profiles.map(x => x.id); - for (var index = 0; index < ids.length; index++) { - var id = ids[index]; - assert.equal(ids.lastIndexOf(id), index); } - done(); }); - */ + + return areEqual; + } + + test('allGroups should return groups from user and workspace settings', () => { + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionGroupsArrayName)) + .returns(() => configValueToConcat); + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + let allGroups = config.getAllGroups(); + + + assert.notEqual(allGroups, undefined); + assert.equal(allGroups.length, configValueToConcat.workspace.length + configValueToConcat.user.length); + }); + + test('allGroups should merge groups from user and workspace settings', () => { + let expectedAllGroups: IConnectionProfileGroup[] = [ + { + name: 'g1', + id: 'g1', + parentId: '', + color: 'pink', + description: 'g1' + }, + { + name: 'g1-1', + id: 'g1-1', + parentId: 'g1', + color: 'blue', + description: 'g1-1' + }, + { + name: 'g2', + id: 'g2', + parentId: '', + color: 'yellow', + description: 'g2' + }, + { + name: 'g2-1', + id: 'g2-1', + parentId: 'g2', + color: 'red', + description: 'g2' + }, + { + name: 'g1-2', + id: 'g1-2', + parentId: 'g1', + color: 'green', + description: 'g1-2' + }]; + + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionGroupsArrayName)) + .returns(() => configValueToMerge); + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + let allGroups = config.getAllGroups(); + + + assert.notEqual(allGroups, undefined); + assert.equal(groupsAreEqual(allGroups, expectedAllGroups), true); + }); + + test('addConnection should add the new profile to user settings if does not exist', done => { + let newProfile: IConnectionProfile = { + serverName: 'new server', + databaseName: 'database', + userName: 'user', + password: 'password', + authenticationType: '', + savePassword: true, + groupFullName: undefined, + groupId: undefined, + getOptionsKey: undefined, + matches: undefined, + providerName: 'MSSQL', + options: {}, + saveProfile: true, + id: undefined, + connectionName: undefined + }; + + let expectedNumberOfConnections = connections.user.length + 1; + + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connections); + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionGroupsArrayName)) + .returns(() => configValueToConcat); + + let connectionProfile = new ConnectionProfile(capabilitiesService.object, newProfile); + connectionProfile.options['databaseDisplayName'] = 'database'; + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + config.addConnection(connectionProfile).then(savedConnectionProfile => { + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); + assert.notEqual(savedConnectionProfile.id, undefined); + done(); + }).catch(error => { + done(error); + }); + }); + + test('addConnection should not add the new profile to user settings if already exists', done => { + let profileFromConfig = connections.user[0]; + let newProfile: IConnectionProfile = { + serverName: profileFromConfig.options['serverName'], + databaseName: profileFromConfig.options['databaseName'], + userName: profileFromConfig.options['userName'], + password: profileFromConfig.options['password'], + authenticationType: profileFromConfig.options['authenticationType'], + groupId: profileFromConfig.groupId, + savePassword: true, + groupFullName: undefined, + getOptionsKey: undefined, + matches: undefined, + providerName: 'MSSQL', + options: {}, + saveProfile: true, + id: undefined, + connectionName: undefined + }; + + let expectedNumberOfConnections = connections.user.length; + + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connections); + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionGroupsArrayName)) + .returns(() => configValueToConcat); + + let connectionProfile = new ConnectionProfile(capabilitiesService.object, newProfile); + connectionProfile.options['databaseDisplayName'] = profileFromConfig.options['databaseName']; + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + config.addConnection(connectionProfile).then(savedConnectionProfile => { + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); + assert.equal(savedConnectionProfile.id, profileFromConfig.id); + done(); + }).catch(error => { + done(error); + }); + }); + + test('addConnection should add the new group to user settings if does not exist', done => { + let newProfile: IConnectionProfile = { + serverName: 'new server', + databaseName: 'database', + userName: 'user', + password: 'password', + authenticationType: '', + savePassword: true, + groupFullName: 'g2/g2-2', + groupId: undefined, + getOptionsKey: undefined, + matches: undefined, + providerName: 'MSSQL', + options: {}, + saveProfile: true, + id: undefined, + connectionName: undefined + }; + + let expectedNumberOfConnections = connections.user.length + 1; + let expectedNumberOfGroups = configValueToConcat.user.length + 1; + + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connections); + + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionGroupsArrayName)) + .returns(() => configValueToConcat); + + let connectionProfile = new ConnectionProfile(capabilitiesService.object, newProfile); + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + config.addConnection(connectionProfile).then(success => { + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileGroup[]).length === expectedNumberOfGroups)), TypeMoq.Times.once()); + done(); + }).catch(error => { + done(error); + }); + }); + + test('getConnections should return connections from user and workspace settings given getWorkspaceConnections set to true', () => { + let getWorkspaceConnections: boolean = true; + + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connections); + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + let allConnections = config.getConnections(getWorkspaceConnections); + assert.equal(allConnections.length, connections.user.length + connections.workspace.length); + }); + + test('getConnections should return connections from user settings given getWorkspaceConnections set to false', () => { + let getWorkspaceConnections: boolean = false; + + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connections); + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + let allConnections = config.getConnections(getWorkspaceConnections); + assert.equal(allConnections.length, connections.user.length); + }); + + test('getConnections should return connections with a valid id', () => { + let getWorkspaceConnections: boolean = false; + let connectionsWithNoId = { + user: connections.user.map(c => { + c.id = undefined; + return c; + }), + default: connections.default, + workspace: connections.workspace.map(c => { + c.id = c.options['serverName']; + return c; + }), + value: connections.value, + workspaceFolder: [] + }; + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connectionsWithNoId); + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + let allConnections = config.getConnections(getWorkspaceConnections); + assert.equal(allConnections.length, connections.user.length); + allConnections.forEach(connection => { + let userConnection = connectionsWithNoId.user.find(u => u.options['serverName'] === connection.serverName); + if (userConnection !== undefined) { + assert.notEqual(connection.id, connection.getOptionsKey()); + assert.notEqual(connection.id, undefined); + } else { + let workspaceConnection = connectionsWithNoId.workspace.find(u => u.options['serverName'] === connection.serverName); + assert.notEqual(connection.id, connection.getOptionsKey()); + assert.equal(workspaceConnection.id, connection.id); + } + }); + }); + + test('getConnections update the capabilities in each profile when the provider capabilities is registered', () => { + let oldOptionName: string = 'oldOptionName'; + let optionsMetadataFromConfig = capabilities[0].connection.connectionOptions.concat({ + name: oldOptionName, + displayName: undefined, + description: undefined, + groupName: undefined, + categoryValues: undefined, + defaultValue: undefined, + isIdentity: true, + isRequired: true, + specialValueType: undefined, + valueType: ServiceOptionType.string + }); + + let capabilitiesFromConfig: ProviderFeatures[] = []; + let msSQLCapabilities2: ProviderFeatures = { + connection: { + providerId: 'MSSQL', + displayName: 'MSSQL', + connectionOptions: optionsMetadataFromConfig + } + }; + capabilitiesFromConfig.push(msSQLCapabilities2); + let connectionUsingOldMetadata = connections.user.map(c => { + c.options[oldOptionName] = 'oldOptionValue'; + return c; + }); + let configValue = Object.assign({}, connections, { user: connectionUsingOldMetadata }); + let capabilitiesService2: TypeMoq.Mock = TypeMoq.Mock.ofType(CapabilitiesTestService); + capabilitiesService2.setup(x => x.getCapabilities('MSSQL')).returns(() => msSQLCapabilities2); + (capabilitiesService2.object as any).onCapabilitiesRegistered = onCapabilitiesRegistered.event; + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => configValue); + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService2.object); + let allConnections = config.getConnections(false); + allConnections.forEach(element => { + assert.notEqual(element.serverName, undefined); + assert.notEqual(element.getOptionsKey().indexOf('oldOptionValue|'), -1); + }); + + onCapabilitiesRegistered.fire(msSQLCapabilities); + allConnections.forEach(element => { + assert.notEqual(element.serverName, undefined); + assert.equal(element.getOptionsKey().indexOf('oldOptionValue|'), -1); + }); + }); + + test('saveGroup should save the new groups to tree and return the id of the last group name', () => { + let config = new ConnectionConfig(undefined, undefined, undefined); + let groups: IConnectionProfileGroup[] = configValueToConcat.user; + let expectedLength = configValueToConcat.user.length + 2; + let newGroups: string = 'ROOT/g1/g1-1'; + let color: string = 'red'; + + let result: ISaveGroupResult = config.saveGroup(groups, newGroups, color, newGroups); + assert.notEqual(result, undefined); + assert.equal(result.groups.length, expectedLength, 'The result groups length is invalid'); + let newGroup = result.groups.find(g => g.name === 'g1-1'); + assert.equal(result.newGroupId, newGroup.id, 'The groups id is invalid'); + }); + + test('saveGroup should only add the groups that are not in the tree', () => { + let config = new ConnectionConfig(undefined, undefined, undefined); + let groups: IConnectionProfileGroup[] = configValueToConcat.user; + let expectedLength = configValueToConcat.user.length + 1; + let newGroups: string = 'ROOT/g2/g2-5'; + let color: string = 'red'; + + let result: ISaveGroupResult = config.saveGroup(groups, newGroups, color, newGroups); + assert.notEqual(result, undefined); + assert.equal(result.groups.length, expectedLength, 'The result groups length is invalid'); + let newGroup = result.groups.find(g => g.name === 'g2-5'); + assert.equal(result.newGroupId, newGroup.id, 'The groups id is invalid'); + }); + + test('saveGroup should not add any new group if tree already has all the groups in the full path', () => { + let config = new ConnectionConfig(undefined, undefined, undefined); + let groups: IConnectionProfileGroup[] = configValueToConcat.user; + let expectedLength = configValueToConcat.user.length; + let newGroups: string = 'ROOT/g2/g2-1'; + let color: string = 'red'; + + let result: ISaveGroupResult = config.saveGroup(groups, newGroups, color, newGroups); + assert.notEqual(result, undefined); + assert.equal(result.groups.length, expectedLength, 'The result groups length is invalid'); + let newGroup = result.groups.find(g => g.name === 'g2-1'); + assert.equal(result.newGroupId, newGroup.id, 'The groups id is invalid'); + }); + + test('deleteConnection should remove the connection from config', done => { + let newProfile: IConnectionProfile = { + serverName: 'server3', + databaseName: 'database', + userName: 'user', + password: 'password', + authenticationType: '', + savePassword: true, + groupFullName: 'g3', + groupId: 'g3', + getOptionsKey: undefined, + matches: undefined, + providerName: 'MSSQL', + options: {}, + saveProfile: true, + id: undefined, + connectionName: undefined + }; + + let expectedNumberOfConnections = connections.user.length - 1; + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connections); + + let connectionProfile = new ConnectionProfile(capabilitiesService.object, newProfile); + connectionProfile.options['databaseDisplayName'] = 'database'; + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + config.deleteConnection(connectionProfile).then(() => { + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); + done(); + }).catch(error => { + done(error); + }); + }); + + test('deleteConnectionGroup should remove the children connections and subgroups from config', done => { + let newProfile: IConnectionProfile = { + serverName: 'server3', + databaseName: 'database', + userName: 'user', + password: 'password', + authenticationType: '', + savePassword: true, + groupFullName: 'g3', + groupId: 'g3', + getOptionsKey: undefined, + matches: undefined, + providerName: 'MSSQL', + options: {}, + saveProfile: true, + id: undefined, + connectionName: undefined + }; + let connectionProfile = new ConnectionProfile(capabilitiesService.object, newProfile); + connectionProfile.options['databaseDisplayName'] = 'database'; + + let connectionProfileGroup = new ConnectionProfileGroup('g3', undefined, 'g3', undefined, undefined); + let childGroup = new ConnectionProfileGroup('g3-1', connectionProfileGroup, 'g3-1', undefined, undefined); + connectionProfileGroup.addGroups([childGroup]); + connectionProfileGroup.addConnections([connectionProfile]); + + let expectedNumberOfConnections = connections.user.length - 1; + let expectedNumberOfGroups = configValueToConcat.user.length - 2; + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connections); + + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionGroupsArrayName)) + .returns(() => configValueToConcat); + + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + config.deleteGroup(connectionProfileGroup).then(() => { + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileGroup[]).length === expectedNumberOfGroups)), TypeMoq.Times.once()); + done(); + }).catch(error => { + done(error); + }); + }); + + test('deleteConnection should not throw error for connection not in config', done => { + let newProfile: IConnectionProfile = { + serverName: 'server3', + databaseName: 'database', + userName: 'user', + password: 'password', + authenticationType: '', + savePassword: true, + groupFullName: 'g3', + groupId: 'newid', + getOptionsKey: undefined, + matches: undefined, + providerName: 'MSSQL', + options: {}, + saveProfile: true, + id: undefined, + connectionName: undefined + }; + + let expectedNumberOfConnections = connections.user.length; + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connections); + + let connectionProfile = new ConnectionProfile(capabilitiesService.object, newProfile); + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + config.deleteConnection(connectionProfile).then(() => { + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); + done(); + }).catch(error => { + done(error); + }); + }); + + test('renameGroup should change group name', done => { + + let expectedNumberOfConnections = configValueToConcat.user.length; + let calledValue: any; + let called: boolean = false; + let nothing: void; + let configEditingServiceMock: TypeMoq.Mock = TypeMoq.Mock.ofType(ConfigurationEditingService); + configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.USER, TypeMoq.It.isAny())).callback((x: any, val: any) => { + calledValue = val.value as IConnectionProfileStore[]; + }).returns(() => TPromise.as(nothing)); + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionGroupsArrayName)) + .returns(() => configValueToConcat); + + let connectionProfileGroup = new ConnectionProfileGroup('g-renamed', undefined, 'g2', undefined, undefined); + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + config.editGroup(connectionProfileGroup).then(() => { + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); + calledValue.forEach(con => { + if (con.id === 'g2') { + assert.equal(con.name, 'g-renamed', 'Group was not renamed'); + called = true; + } + }); + assert.equal(called, true, 'group was not renamed'); + }).then(() => done(), (err) => done(err)); + }); + + + test('change group(parent) for connection group', done => { + let expectedNumberOfConnections = configValueToConcat.user.length; + let calledValue: any; + let nothing: void; + let configEditingServiceMock: TypeMoq.Mock = TypeMoq.Mock.ofType(ConfigurationEditingService); + configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.USER, TypeMoq.It.isAny())).callback((x: any, val: any) => { + calledValue = val.value as IConnectionProfileStore[]; + }).returns(() => TPromise.as(nothing)); + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionGroupsArrayName)) + .returns(() => configValueToConcat); + + let sourceProfileGroup = new ConnectionProfileGroup('g2', undefined, 'g2', undefined, undefined); + let targetProfileGroup = new ConnectionProfileGroup('g3', undefined, 'g3', undefined, undefined); + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + config.changeGroupIdForConnectionGroup(sourceProfileGroup, targetProfileGroup).then(() => { + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.once()); + calledValue.forEach(con => { + if (con.id === 'g2') { + assert.equal(con.parentId, 'g3', 'Group parent was not changed'); + } + }); + }).then(() => done(), (err) => done(err)); + }); + + + test('change group(parent) for connection', done => { + let newProfile: IConnectionProfile = { + serverName: 'server3', + databaseName: 'database', + userName: 'user', + password: 'password', + authenticationType: '', + savePassword: true, + groupFullName: 'g3', + groupId: 'g3', + getOptionsKey: () => { return 'connectionId'; }, + matches: undefined, + providerName: 'MSSQL', + options: {}, + saveProfile: true, + id: 'test', + connectionName: undefined + }; + + let expectedNumberOfConnections = connections.user.length; + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connections); + + let connectionProfile = new ConnectionProfile(capabilitiesService.object, newProfile); + let newId = 'newid'; + let calledValue: any; + let nothing: void; + let configEditingServiceMock: TypeMoq.Mock = TypeMoq.Mock.ofType(ConfigurationEditingService); + configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.USER, TypeMoq.It.isAny())).callback((x: any, val: any) => { + calledValue = val.value as IConnectionProfileStore[]; + }).returns(() => TPromise.as(nothing)); + configEditingServiceMock.setup(x => x.writeConfiguration(ConfigurationTarget.WORKSPACE, TypeMoq.It.isAny())).callback((x: any, val: any) => { + + }).returns(() => TPromise.as(nothing)); + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + config.changeGroupIdForConnection(connectionProfile, newId).then(() => { + configEditingServiceMock.verify(y => y.writeConfiguration(ConfigurationTarget.USER, + TypeMoq.It.is(c => (c.value as IConnectionProfileStore[]).length === expectedNumberOfConnections)), TypeMoq.Times.atLeastOnce()); + calledValue.forEach(con => { + }); + }).then(() => done(), (err) => done(err)); + }); test('fixConnectionIds should replace duplicate ids with new ones', (done) => { + let profiles: IConnectionProfileStore[] = [ + { + options: {}, + groupId: '1', + id: '1', + providerName: undefined, + savePassword: true, + }, { + options: {}, + groupId: '1', + id: '2', + providerName: undefined, + savePassword: true, + }, { + options: {}, + groupId: '1', + id: '3', + providerName: undefined, + savePassword: true, + }, { + options: {}, + groupId: '1', + id: '2', + providerName: undefined, + savePassword: true, + }, { + options: {}, + groupId: '1', + id: '4', + providerName: undefined, + savePassword: true, + }, { + options: {}, + groupId: '1', + id: '3', + providerName: undefined, + savePassword: true, + }, { + options: {}, + groupId: '1', + id: '2', + providerName: undefined, + savePassword: true, + } + ]; + + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + config.fixConnectionIds(profiles); + let ids = profiles.map(x => x.id); + for (var index = 0; index < ids.length; index++) { + var id = ids[index]; + assert.equal(ids.lastIndexOf(id), index); + } done(); }); + test('addConnection should not move the connection when editing', async () => { + // Set up the connection config + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionsArrayName)) + .returns(() => connections); + workspaceConfigurationServiceMock.setup(x => x.inspect( + Constants.connectionGroupsArrayName)) + .returns(() => configValueToConcat); + let config = new ConnectionConfig(configEditingServiceMock.object, workspaceConfigurationServiceMock.object, capabilitiesService.object); + + // Clone a connection and modify an option + const connectionIndex = 1; + const optionKey = 'testOption'; + const optionValue = 'testValue'; + let allConnections = config.getConnections(false); + let oldLength = allConnections.length; + let connectionToEdit = allConnections[connectionIndex].clone(); + connectionToEdit.options[optionKey] = optionValue; + await config.addConnection(connectionToEdit); + + // Get the connection and verify that it is in the same place and has been updated + let newConnections = config.getConnections(false); + assert.equal(newConnections.length, oldLength); + let editedConnection = newConnections[connectionIndex]; + assert.equal(editedConnection.getOptionsKey(), connectionToEdit.getOptionsKey()); + assert.equal(editedConnection.options[optionKey], optionValue); + }); + }); diff --git a/src/sqltest/parts/connection/connectionDialogService.test.ts b/src/sqltest/parts/connection/connectionDialogService.test.ts index d04e8504f4..f660f9e7d1 100644 --- a/src/sqltest/parts/connection/connectionDialogService.test.ts +++ b/src/sqltest/parts/connection/connectionDialogService.test.ts @@ -56,7 +56,8 @@ suite('ConnectionDialogService tests', () => { onConnectStart: undefined, onConnectSuccess: undefined, onConnectReject: undefined, - onDisconnect: undefined + onDisconnect: undefined, + onConnectCanceled: undefined }, runQueryOnCompletion: undefined, querySelection: undefined diff --git a/src/sqltest/parts/connection/connectionManagementService.test.ts b/src/sqltest/parts/connection/connectionManagementService.test.ts index 08797fab38..4594b059cd 100644 --- a/src/sqltest/parts/connection/connectionManagementService.test.ts +++ b/src/sqltest/parts/connection/connectionManagementService.test.ts @@ -259,6 +259,7 @@ suite('SQL ConnectionManagementService tests', () => { onConnectStart: undefined, onDisconnect: undefined, onConnectSuccess: undefined, + onConnectCanceled: undefined, uri: 'Editor Uri' }, runQueryOnCompletion: RunQueryOnConnectionMode.executeQuery @@ -279,6 +280,7 @@ suite('SQL ConnectionManagementService tests', () => { onConnectStart: undefined, onDisconnect: undefined, onConnectSuccess: undefined, + onConnectCanceled: undefined, uri: 'Editor Uri' }, runQueryOnCompletion: RunQueryOnConnectionMode.executeQuery @@ -348,6 +350,7 @@ suite('SQL ConnectionManagementService tests', () => { onConnectReject: undefined, onConnectStart: undefined, onDisconnect: undefined, + onConnectCanceled: undefined, uri: uri }, querySelection: undefined, @@ -661,6 +664,7 @@ suite('SQL ConnectionManagementService tests', () => { onConnectReject: undefined, onConnectStart: undefined, onDisconnect: undefined, + onConnectCanceled: undefined, uri: uri }, querySelection: undefined, diff --git a/src/sqltest/parts/connection/objectExplorerService.test.ts b/src/sqltest/parts/connection/objectExplorerService.test.ts index db324406ec..67cc030fb5 100644 --- a/src/sqltest/parts/connection/objectExplorerService.test.ts +++ b/src/sqltest/parts/connection/objectExplorerService.test.ts @@ -447,10 +447,11 @@ suite('SQL Object Explorer Service tests', () => { objectExplorerService.updateObjectExplorerNodes(connection).then(() => { var treeNode = objectExplorerService.getObjectExplorerNode(connection); assert.equal(treeNode !== null && treeNode !== undefined, true); - objectExplorerService.deleteObjectExplorerNode(connection); - treeNode = objectExplorerService.getObjectExplorerNode(connection); - assert.equal(treeNode === null || treeNode === undefined, true); - done(); + objectExplorerService.deleteObjectExplorerNode(connection).then(() => { + treeNode = objectExplorerService.getObjectExplorerNode(connection); + assert.equal(treeNode === null || treeNode === undefined, true); + done(); + }); }, err => { // Must call done here so test indicates it's finished if errors occur done(err); @@ -753,4 +754,43 @@ suite('SQL Object Explorer Service tests', () => { assert.equal(childNode.nodePath, objectExplorerExpandInfoRefresh.nodes[index].nodePath); }); }); + + test('Session can be closed even if expand requests are pending', async () => { + const providerId = 'MSSQL'; + + // Set up the session + await objectExplorerService.createNewSession(providerId, connection); + objectExplorerService.onSessionCreated(1, objectExplorerSession); + + // Set up the provider to not respond to the second expand request, simulating a request that takes a long time to complete + const nodePath = objectExplorerSession.rootNode.nodePath; + sqlOEProvider.setup(x => x.expandNode(TypeMoq.It.is(x => x.nodePath === nodePath))).callback(() => { }).returns(() => TPromise.as(true)); + + // If I queue a second expand request (the first completes normally because of the original mock) and then close the session + await objectExplorerService.expandNode(providerId, objectExplorerSession, objectExplorerSession.rootNode.nodePath); + let expandPromise = objectExplorerService.expandNode(providerId, objectExplorerSession, objectExplorerSession.rootNode.nodePath); + let closeSessionResult = await objectExplorerService.closeSession(providerId, objectExplorerSession); + + // Then the expand request has completed and the session is closed + let expandResult = await expandPromise; + assert.equal(expandResult.nodes.length, 0); + assert.equal(closeSessionResult.success, true); + }); + + test('resolveTreeNodeChildren refreshes a node if it currently has an error', async () => { + await objectExplorerService.createNewSession('MSSQL', connection); + objectExplorerService.onSessionCreated(1, objectExplorerSession); + + // If I call resolveTreeNodeChildren once, set an error on the node, and then call it again + let tablesNodePath = 'testServerName/tables'; + let tablesNode = new TreeNode(NodeType.Folder, 'Tables', false, tablesNodePath, '', '', null, null, undefined, undefined); + tablesNode.connection = connection; + await objectExplorerService.resolveTreeNodeChildren(objectExplorerSession, tablesNode); + sqlOEProvider.verify(x => x.refreshNode(TypeMoq.It.is(x => x.nodePath === tablesNodePath)), TypeMoq.Times.never()); + tablesNode.errorStateMessage = 'test error message'; + await objectExplorerService.resolveTreeNodeChildren(objectExplorerSession, tablesNode); + + // Then refresh gets called on the node + sqlOEProvider.verify(x => x.refreshNode(TypeMoq.It.is(x => x.nodePath === tablesNodePath)), TypeMoq.Times.once()); + }); }); \ No newline at end of file diff --git a/src/vs/base/parts/tree/browser/treeModel.ts b/src/vs/base/parts/tree/browser/treeModel.ts index 3d5600b732..6bef5e9f71 100644 --- a/src/vs/base/parts/tree/browser/treeModel.ts +++ b/src/vs/base/parts/tree/browser/treeModel.ts @@ -361,12 +361,6 @@ export class Item { return WinJS.TPromise.as(false); } - // {{SQL CARBON EDIT}} - Original code does not handle the need to refresh children in case previous refreshchildren errored out. - // resetting the errorStateMessage before we refresh children so we can track if there has been error in processing. - if (this.element instanceof TreeNode) { - this.element.errorStateMessage = null; - } - var result = this.lock.run(this, () => { var eventData: IItemExpandEvent = { item: this }; var result: WinJS.Promise; diff --git a/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts b/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts index d09c2ec571..85bde7ce9e 100644 --- a/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts +++ b/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts @@ -868,6 +868,9 @@ export class TabsTitleControl extends TitleControl { // Dirty State this.redrawEditorDirty(editor, tabContainer); + + // {{SQL CARBON EDIT}} -- Display the editor's tab color + this.setEditorTabColor(editor, tabContainer, this.group.isActive(editor)); } private redrawLabel(editor: IEditorInput, tabContainer: HTMLElement, tabLabelWidget: ResourceLabel, tabLabel: IEditorInputLabel): void { @@ -880,7 +883,8 @@ export class TabsTitleControl extends TitleControl { tabContainer.title = title; // Label - tabLabelWidget.setLabel({ name, description, resource: toResource(editor, { supportSideBySide: true }) }, { extraClasses: ['tab-label'], italic: !this.group.isPinned(editor) }); + // {{SQL CARBON EDIT}} -- add title in options passed + tabLabelWidget.setLabel({ name, description, resource: toResource(editor, { supportSideBySide: true }) }, { extraClasses: ['tab-label'], italic: !this.group.isPinned(editor), title }); // {{SQL CARBON EDIT}} -- Display the editor's tab color const isTabActive = this.group.isActive(editor); @@ -1105,10 +1109,12 @@ export class TabsTitleControl extends TitleControl { || this.themeService.getTheme().type === HIGH_CONTRAST || !sqlEditor.tabColor) { tabContainer.style.borderTopColor = ''; tabContainer.style.borderTopWidth = ''; + tabContainer.style.borderTopStyle = ''; return; } tabContainer.style.borderTopColor = sqlEditor.tabColor; tabContainer.style.borderTopWidth = isTabActive ? '3px' : '2px'; + tabContainer.style.borderTopStyle = 'solid'; if (tabColorMode === QueryConstants.tabColorModeFill) { let backgroundColor = Color.Format.CSS.parseHex(sqlEditor.tabColor); if (backgroundColor) { diff --git a/src/vs/workbench/parts/logs/common/logConstants.ts b/src/vs/workbench/parts/logs/common/logConstants.ts index f75a511917..8302b6242a 100644 --- a/src/vs/workbench/parts/logs/common/logConstants.ts +++ b/src/vs/workbench/parts/logs/common/logConstants.ts @@ -6,4 +6,6 @@ export const mainLogChannelId = 'mainLog'; export const sharedLogChannelId = 'sharedLog'; export const rendererLogChannelId = 'rendererLog'; -export const extHostLogChannelId = 'extHostLog'; \ No newline at end of file +export const extHostLogChannelId = 'extHostLog'; +// {{SQL CARBON EDIT}} +export const sqlToolsLogChannellId = 'sqlToolsLog'; diff --git a/src/vs/workbench/parts/logs/electron-browser/logs.contribution.ts b/src/vs/workbench/parts/logs/electron-browser/logs.contribution.ts index c3a9afeee2..6259308590 100644 --- a/src/vs/workbench/parts/logs/electron-browser/logs.contribution.ts +++ b/src/vs/workbench/parts/logs/electron-browser/logs.contribution.ts @@ -18,14 +18,18 @@ import * as Constants from 'vs/workbench/parts/logs/common/logConstants'; import { IWorkbenchActionRegistry, Extensions as WorkbenchActionExtensions } from 'vs/workbench/common/actions'; import { SyncActionDescriptor } from 'vs/platform/actions/common/actions'; import { ShowLogsAction, OpenLogsFolderAction, SetLogLevelAction, OpenLogFileAction } from 'vs/workbench/parts/logs/electron-browser/logsActions'; - +// {{SQL CARBON EDIT}} +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { ExtensionService } from 'vs/workbench/services/extensions/electron-browser/extensionService'; class LogOutputChannels extends Disposable implements IWorkbenchContribution { constructor( @IWindowService private windowService: IWindowService, @IEnvironmentService private environmentService: IEnvironmentService, - @IInstantiationService instantiationService: IInstantiationService + @IInstantiationService instantiationService: IInstantiationService, + // {{SQL CARBON EDIT}} + @IExtensionService private extensionService: ExtensionService ) { super(); let outputChannelRegistry = Registry.as(OutputExt.OutputChannels); @@ -33,6 +37,12 @@ class LogOutputChannels extends Disposable implements IWorkbenchContribution { outputChannelRegistry.registerChannel(Constants.sharedLogChannelId, nls.localize('sharedLog', "Log (Shared)"), URI.file(join(this.environmentService.logsPath, `sharedprocess.log`))); outputChannelRegistry.registerChannel(Constants.rendererLogChannelId, nls.localize('rendererLog', "Log (Window)"), URI.file(join(this.environmentService.logsPath, `renderer${this.windowService.getCurrentWindowId()}.log`))); outputChannelRegistry.registerChannel(Constants.extHostLogChannelId, nls.localize('extensionsLog', "Log (Extension Host)"), URI.file(join(this.environmentService.logsPath, `exthost${this.windowService.getCurrentWindowId()}.log`))); + // {{SQL CARBON EDIT}} + let extHostPid : number = extensionService.getExtenstionHostProcessId(); + console.log(`extensionHost process id is ${extHostPid}`); + let toolsServiceLogFile : string = join(this.environmentService.logsPath, '..', '..', 'mssql', `sqltools_${extHostPid}.log`); + console.log(`SqlTools Log file is: ${toolsServiceLogFile}`); + outputChannelRegistry.registerChannel(Constants.sqlToolsLogChannellId, nls.localize('sqlToolsLog', "Log (SqlTools)"), URI.file(toolsServiceLogFile)); const workbenchActionsRegistry = Registry.as(WorkbenchActionExtensions.WorkbenchActions); const devCategory = nls.localize('developer', "Developer"); diff --git a/src/vs/workbench/parts/logs/electron-browser/logsActions.ts b/src/vs/workbench/parts/logs/electron-browser/logsActions.ts index 88eb7bfc6e..bab378c18a 100644 --- a/src/vs/workbench/parts/logs/electron-browser/logsActions.ts +++ b/src/vs/workbench/parts/logs/electron-browser/logsActions.ts @@ -52,7 +52,9 @@ export class ShowLogsAction extends Action { { id: Constants.rendererLogChannelId, label: this.contextService.getWorkspace().name ? nls.localize('rendererProcess', "Window ({0})", this.contextService.getWorkspace().name) : nls.localize('emptyWindow', "Window") }, { id: Constants.extHostLogChannelId, label: nls.localize('extensionHost', "Extension Host") }, { id: Constants.sharedLogChannelId, label: nls.localize('sharedProcess', "Shared") }, - { id: Constants.mainLogChannelId, label: nls.localize('mainProcess', "Main") } + { id: Constants.mainLogChannelId, label: nls.localize('mainProcess', "Main") }, + // {{SQL CARBON EDIT}} + { id: Constants.sqlToolsLogChannellId, label: nls.localize('sqlToolsHost', "SqlTools") } ]; return this.quickOpenService.pick(entries, { placeHolder: nls.localize('selectProcess', "Select Log for Process") }) diff --git a/src/vs/workbench/services/extensions/electron-browser/extensionHost.ts b/src/vs/workbench/services/extensions/electron-browser/extensionHost.ts index bc9e359308..52b8e6f10e 100644 --- a/src/vs/workbench/services/extensions/electron-browser/extensionHost.ts +++ b/src/vs/workbench/services/extensions/electron-browser/extensionHost.ts @@ -101,6 +101,11 @@ export class ExtensionHostProcessWorker { })); } + // {{SQL CARBON EDIT}} + public getExtenstionHostProcess(): ChildProcess { + return this._extensionHostProcess; + } + public dispose(): void { this.terminate(); } diff --git a/src/vs/workbench/services/extensions/electron-browser/extensionService.ts b/src/vs/workbench/services/extensions/electron-browser/extensionService.ts index 24592aab8e..40e0ba7fa8 100644 --- a/src/vs/workbench/services/extensions/electron-browser/extensionService.ts +++ b/src/vs/workbench/services/extensions/electron-browser/extensionService.ts @@ -152,6 +152,11 @@ export class ExtensionHostProcessManager extends Disposable { }); } + // {{SQL CARBON EDIT}} + public getExtenstionHostProcessWorker(): ExtensionHostProcessWorker { + return this._extensionHostProcessWorker; + } + public dispose(): void { if (this._extensionHostProcessWorker) { this._extensionHostProcessWorker.dispose(); @@ -296,6 +301,14 @@ export class ExtensionService extends Disposable implements IExtensionService { } } + // {{SQL CARBON EDIT}} + public getExtenstionHostProcessId(): number { + if (this._extensionHostProcessManagers.length !== 1) + { + this._logOrShowMessage(Severity.Warning, 'Exactly one Extension Host Process Manager was expected'); + } + return this._extensionHostProcessManagers[0].getExtenstionHostProcessWorker().getExtenstionHostProcess().pid; + } private startDelayed(lifecycleService: ILifecycleService): void { let started = false; const startOnce = () => { diff --git a/yarn.lock b/yarn.lock index f372631195..6af0ff8e4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -178,9 +178,9 @@ angular2-grid@2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/angular2-grid/-/angular2-grid-2.0.6.tgz#01fe225dc13b2822370b6c61f9a6913b3a26f989" -"angular2-slickgrid@github:Microsoft/angular2-slickgrid#1.4.4": - version "1.4.4" - resolved "https://codeload.github.com/Microsoft/angular2-slickgrid/tar.gz/5494e99f3ffdfd119e02bd2a26db5ba3c1e05c1d" +"angular2-slickgrid@github:Microsoft/angular2-slickgrid#1.4.5": + version "1.4.5" + resolved "https://codeload.github.com/Microsoft/angular2-slickgrid/tar.gz/4a4dd9333f6295c9539c2c3f9a945a204c9a6449" ansi-colors@^1.0.1: version "1.1.0" @@ -2159,6 +2159,13 @@ find-parent-dir@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.0.tgz#33c44b429ab2b2f0646299c5f9f718f376ff8d54" +find-remove@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/find-remove/-/find-remove-1.2.1.tgz#afd93400d23890e018ea197591e9d850d3d049a2" + dependencies: + fmerge "1.2.0" + rimraf "2.6.2" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -2225,6 +2232,10 @@ flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" +fmerge@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fmerge/-/fmerge-1.2.0.tgz#36e99d2ae255e3ee1af666b4df780553671cf692" + for-in@^0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.5.tgz#007374e2b6d5c67420a1479bdb75a04872b738c4" @@ -5892,15 +5903,15 @@ right-align@^0.1.1: dependencies: align-text "^0.1.1" -rimraf@^2.2.8: - version "2.6.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.1.tgz#c2338ec643df7a1b7fe5c54fa86f57428a55f33d" +rimraf@2.6.2, rimraf@^2.4.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" dependencies: glob "^7.0.5" -rimraf@^2.4.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" +rimraf@^2.2.8: + version "2.6.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.1.tgz#c2338ec643df7a1b7fe5c54fa86f57428a55f33d" dependencies: glob "^7.0.5"