diff --git a/extension.bundle.ts b/extension.bundle.ts index fdb1a8a29..e1bab9eea 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -24,26 +24,29 @@ export { emulatorPassword, isWindows } from './src/constants'; export { ParsedDocDBConnectionString, parseDocDBConnectionString } from './src/docdb/docDBConnectionStrings'; export { getCosmosClient } from './src/docdb/getCosmosClient'; export * from './src/docdb/registerDocDBCommands'; -export { activateInternal, cosmosDBCopyConnectionString, createServer, deactivateInternal, deleteAccount } from './src/extension'; +export { activateInternal, deactivateInternal } from './src/extension'; export { ext } from './src/extensionVariables'; export * from './src/graph/registerGraphCommands'; -export { MongoCommand } from './src/mongo/MongoCommand'; -export { findCommandAtPosition, getAllCommandsFromText } from './src/mongo/MongoScrapbook'; -export { MongoShell } from './src/mongo/MongoShell'; export { connectToMongoClient, isCosmosEmulatorConnectionString } from './src/mongo/connectToMongoClient'; -export { addDatabaseToAccountConnectionString, encodeMongoConnectionString, getDatabaseNameFromConnectionString } from './src/mongo/mongoConnectionStrings'; +export { MongoCommand } from './src/mongo/MongoCommand'; +export { + addDatabaseToAccountConnectionString, + encodeMongoConnectionString, + getDatabaseNameFromConnectionString +} from './src/mongo/mongoConnectionStrings'; +export { findCommandAtPosition, getAllCommandsFromText } from './src/mongo/MongoScrapbookHelpers'; +export { MongoShellScriptRunner as MongoShell } from './src/mongo/MongoShellScriptRunner'; export * from './src/mongo/registerMongoCommands'; export { IDatabaseInfo } from './src/mongo/tree/MongoAccountTreeItem'; export { addDatabaseToConnectionString } from './src/postgres/postgresConnectionStrings'; +export { SettingUtils } from './src/services/SettingsService'; export { AttachedAccountsTreeItem, MONGO_CONNECTION_EXPECTED } from './src/tree/AttachedAccountsTreeItem'; -export { AzureAccountTreeItemWithAttached } from './src/tree/AzureAccountTreeItemWithAttached'; export * from './src/utils/azureClients'; export { getPublicIpv4, isIpInRanges } from './src/utils/getIp'; export { improveError } from './src/utils/improveError'; export { randomUtils } from './src/utils/randomUtils'; -export { getGlobalSetting, updateGlobalSetting } from './src/utils/settingUtils'; export { rejectOnTimeout, valueOnTimeout } from './src/utils/timeout'; -export { IDisposable, getDocumentTreeItemLabel } from './src/utils/vscodeUtils'; +export { getDocumentTreeItemLabel, IDisposable } from './src/utils/vscodeUtils'; export { wrapError } from './src/utils/wrapError'; // NOTE: The auto-fix action "source.organizeImports" does weird things with this file, but there doesn't seem to be a way to disable it on a per-file basis so we'll just let it happen diff --git a/package-lock.json b/package-lock.json index 93e4ba24a..1dfea7fb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@azure/arm-cosmosdb": "16.0.0-beta.7", "@azure/arm-postgresql": "^6.1.0", "@azure/arm-postgresql-flexible": "^7.1.0", + "@azure/arm-resources": "^5.2.0", "@azure/cosmos": "^4.1.1", "@fluentui/react-components": "^9.56.2", "@fluentui/react-icons": "^2.0.265", @@ -269,6 +270,23 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" }, + "node_modules/@azure/arm-resources": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@azure/arm-resources/-/arm-resources-5.2.0.tgz", + "integrity": "sha512-wQyuhL8WQsLkW/KMdik8bLJIJCz3Z6mg/+AKm0KedgK73SKhicSqYP+ed3t+43tLlRFltcrmGKMcHLQ+Jhv/6A==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.7.0", + "@azure/core-lro": "^2.5.0", + "@azure/core-paging": "^1.2.0", + "@azure/core-rest-pipeline": "^1.8.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@azure/arm-resources-profile-2020-09-01-hybrid": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@azure/arm-resources-profile-2020-09-01-hybrid/-/arm-resources-profile-2020-09-01-hybrid-2.0.0.tgz", @@ -311,6 +329,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" }, + "node_modules/@azure/arm-resources/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/@azure/arm-storage": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/@azure/arm-storage/-/arm-storage-18.2.0.tgz", @@ -4286,28 +4309,6 @@ "@azure/ms-rest-azure-env": "^2.0.0" } }, - "node_modules/@microsoft/vscode-azext-azureutils/node_modules/@azure/arm-resources": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@azure/arm-resources/-/arm-resources-5.2.0.tgz", - "integrity": "sha512-wQyuhL8WQsLkW/KMdik8bLJIJCz3Z6mg/+AKm0KedgK73SKhicSqYP+ed3t+43tLlRFltcrmGKMcHLQ+Jhv/6A==", - "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-client": "^1.7.0", - "@azure/core-lro": "^2.5.0", - "@azure/core-paging": "^1.2.0", - "@azure/core-rest-pipeline": "^1.8.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@microsoft/vscode-azext-azureutils/node_modules/tslib": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", - "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" - }, "node_modules/@microsoft/vscode-azext-azureutils/node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", diff --git a/package.json b/package.json index ef951e2ce..e69d7c6fd 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ }, "dependencies": { "@azure/arm-cosmosdb": "16.0.0-beta.7", + "@azure/arm-resources": "^5.2.0", "@azure/arm-postgresql": "^6.1.0", "@azure/arm-postgresql-flexible": "^7.1.0", "@azure/cosmos": "^4.1.1", @@ -306,7 +307,7 @@ { "category": "MongoDB", "command": "cosmosDB.connectMongoDB", - "title": "Connect to Database..." + "title": "Connect to this database" }, { "category": "Cosmos DB", @@ -354,21 +355,6 @@ "command": "cosmosDB.createGraphDatabase", "title": "Create Database..." }, - { - "category": "MongoDB", - "command": "cosmosDB.createMongoCollection", - "title": "Create Collection..." - }, - { - "category": "MongoDB", - "command": "cosmosDB.createMongoDatabase", - "title": "Create Database..." - }, - { - "category": "MongoDB", - "command": "cosmosDB.createMongoDocument", - "title": "Create Document" - }, { "category": "Cosmos DB", "command": "cosmosDB.deleteAccount", @@ -409,21 +395,6 @@ "command": "cosmosDB.deleteGraphDatabase", "title": "Delete Database..." }, - { - "category": "MongoDB", - "command": "cosmosDB.deleteMongoCollection", - "title": "Delete Collection..." - }, - { - "category": "MongoDB", - "command": "cosmosDB.deleteMongoDB", - "title": "Delete Database..." - }, - { - "category": "MongoDB", - "command": "cosmosDB.deleteMongoDocument", - "title": "Delete Document..." - }, { "category": "MongoDB", "command": "cosmosDB.executeAllMongoCommands", @@ -456,22 +427,12 @@ "command": "cosmosDB.importDocument", "title": "Import Document into a Container..." }, - { - "category": "MongoDB", - "command": "cosmosDB.launchMongoShell", - "title": "Launch Shell" - }, { "category": "MongoDB", "command": "cosmosDB.newMongoScrapbook", "title": "New Mongo Scrapbook", "icon": "$(new-file)" }, - { - "category": "MongoDB", - "command": "cosmosDB.openCollection", - "title": "Open Collection" - }, { "category": "Cosmos DB", "command": "cosmosDB.openDocument", @@ -623,6 +584,11 @@ "command": "command.mongoClusters.exportDocuments", "title": "Export Documents from Collection..." }, + { + "category": "MongoDB Clusters", + "command": "command.mongoClusters.createDocument", + "title": "Create Document..." + }, { "category": "MongoDB Clusters", "command": "command.mongoClusters.launchShell", @@ -632,6 +598,11 @@ "category": "MongoDB Clusters", "command": "command.mongoClusters.removeWorkspaceConnection", "title": "Remove Connection..." + }, + { + "category": "MongoDB Clusters", + "command": "command.mongoClusters.containerView.open", + "title": "Open Collection" } ], "submenus": [ @@ -642,6 +613,14 @@ "dark": "resources/databases.png", "light": "resources/databases.png" } + }, + { + "id": "azureDatabases.submenus.mongo.database.scrapbook", + "label": "Mongo Scrapbook" + }, + { + "id": "azureDatabases.submenus.mongo.collection.scrapbook", + "label": "Mongo Scrapbook" } ], "menus": { @@ -651,6 +630,34 @@ "group": "1_attach@1" } ], + "azureDatabases.submenus.mongo.database.scrapbook": [ + { + "//": "[Database] Mongo DB|Cluster Create Scrapbook", + "command": "cosmosDB.newMongoScrapbook", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@1" + }, + { + "//": "[Database] Connect to Mongo DB (to be removed?)", + "command": "cosmosDB.connectMongoDB", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@2" + } + ], + "azureDatabases.submenus.mongo.collection.scrapbook": [ + { + "//": "[Database] Mongo DB|Cluster Create Scrapbook", + "command": "cosmosDB.newMongoScrapbook", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@1" + }, + { + "//": "[Database] Connect to Mongo DB (to be removed?)", + "command": "cosmosDB.connectMongoDB", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@2" + } + ], "view/title": [ { "submenu": "azureDatabases.submenus.workspaceActions", @@ -721,23 +728,8 @@ "group": "1@1" }, { - "command": "cosmosDB.deleteAccount", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBMongoServer(?![a-z])/i", - "group": "1@2" - }, - { - "command": "cosmosDB.deleteAccount", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBDocumentServer(?![a-z])/i", - "group": "1@2" - }, - { - "command": "cosmosDB.deleteAccount", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBGraphAccount(?![a-z])/i", - "group": "1@2" - }, - { - "command": "cosmosDB.deleteAccount", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBTableAccount(?![a-z])/i", + "command": "azureDatabases.detachDatabaseAccount", + "when": "view == azureWorkspace && viewItem == postgresServerAttached", "group": "1@2" }, { @@ -746,435 +738,384 @@ "group": "1@2" }, { - "command": "cosmosDB.createMongoDatabase", - "when": "view == azureWorkspace && viewItem == cosmosDBMongoServerAttached", - "group": "1@1" - }, - { - "command": "cosmosDB.createMongoDatabase", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBMongoServer(?![a-z])/i", - "group": "1@1" - }, - { - "command": "cosmosDB.createMongoDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoCollection", - "group": "1@1" + "command": "postgreSQL.showPasswordlessWiki", + "when": "view =~ /azure(ResourceGroups|azureFocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i && viewItem =~ /usesPassword/i", + "group": "inline" }, { - "command": "cosmosDB.createMongoCollection", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == mongoDb", + "command": "postgreSQL.createDatabase", + "when": "view =~ /(azureResourceGroups|Workspace|azureFocusView)/ && viewItem =~ /postgresServer/i", "group": "1@1" }, { - "command": "cosmosDB.createDocDBDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentsGroup", + "command": "postgreSQL.deleteDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", "group": "1@2" }, { - "command": "cosmosDB.openNoSqlQueryEditor", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection && config.cosmosDB.preview.queryEditor", - "group": "1@1" + "command": "postgreSQL.deleteTable", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresTable", + "group": "1@2" }, { - "command": "cosmosDB.openNoSqlQueryEditor", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentsGroup && config.cosmosDB.preview.queryEditor", - "group": "1@1" + "command": "postgreSQL.deleteFunction", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunction", + "group": "1@2" }, { - "command": "cosmosDB.writeNoSqlQuery", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection", + "command": "postgreSQL.deleteStoredProcedure", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedure", "group": "1@2" }, { - "command": "cosmosDB.importDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection", - "group": "1@3" + "command": "postgreSQL.copyConnectionString", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", + "group": "2@1" }, { - "command": "cosmosDB.createDocDBStoredProcedure", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBStoredProceduresGroup", + "command": "postgreSQL.connectDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", "group": "1@1" }, { - "command": "cosmosDB.createDocDBTrigger", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBTriggersGroup", + "command": "postgreSQL.createFunctionQuery", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunctions", "group": "1@1" }, { - "command": "cosmosDB.createDocDBCollection", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentDatabase", + "command": "postgreSQL.createStoredProcedureQuery", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedures", "group": "1@1" }, { - "command": "cosmosDB.createDocDBDatabase", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBDocumentServer(?![a-z])/i", - "group": "1@1" + "command": "azureDatabases.refresh", + "when": "view =~ /azureWorkspace/ && viewItem =~ /postgresServer(?![a-z])/i", + "group": "2@2" }, { - "command": "cosmosDB.createDocDBDatabase", - "when": "view == azureWorkspace && viewItem == cosmosDBDocumentServerAttached", - "group": "1@1" + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", + "group": "3@1" }, { - "command": "cosmosDB.createGraphDatabase", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBGraphAccount(?![a-z])/i", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresTables", "group": "1@1" }, { - "command": "cosmosDB.createGraphDatabase", - "when": "view == azureWorkspace && viewItem == cosmosDBGraphAccountAttached", - "group": "1@1" + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunctions", + "group": "2@1" }, { - "command": "cosmosDB.createGraph", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBGraphDatabase", - "group": "1@1" + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedures", + "group": "2@1" }, { - "command": "postgreSQL.showPasswordlessWiki", - "when": "view =~ /azure(ResourceGroups|azureFocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i && viewItem =~ /usesPassword/i", - "group": "inline" + "command": "azureDatabases.refresh", + "when": "view == azureWorkspace && viewItem == postgresServerAttached", + "group": "2@1" }, { - "command": "postgreSQL.createDatabase", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /postgresServer(?![a-z])/i", + "//": "[Account] Create Cosmos DB database", + "command": "cosmosDB.createDocDBDatabase", + "when": "view =~ /azure(ResourceGroups|FocusView|Workspace)/ && viewItem =~ /treeitem[.]account(?![a-z.\\/])/i", "group": "1@1" }, { - "command": "postgreSQL.createDatabase", - "when": "view == azureWorkspace && viewItem == postgresServerAttached", + "//": "[Account] Mongo DB|Cluster Create database", + "command": "command.mongoClusters.createDatabase", + "when": "view =~ /azure(ResourceGroups|FocusView|Workspace)/ && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", "group": "1@1" }, { - "command": "azureDatabases.detachDatabaseAccount", - "when": "view == azureWorkspace && viewItem == cosmosDBMongoServerAttached", + "//": "[Account] Delete Cosmos DB account", + "command": "cosmosDB.deleteAccount", + "when": "view =~ /azure(ResourceGroups|FocusView)/ && viewItem =~ /treeitem[.]account(?![a-z.\\/])/i", "group": "1@2" }, { - "command": "azureDatabases.detachDatabaseAccount", - "when": "view == azureWorkspace && viewItem == cosmosDBGraphAccountAttached", + "//": "[Account] Delete Mongo DB account", + "command": "cosmosDB.deleteAccount", + "when": "view =~ /azure(ResourceGroups|FocusView)/ && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", "group": "1@2" }, { + "//": "[Account] Detach Cosmos DB account (workspace only)", "command": "azureDatabases.detachDatabaseAccount", - "when": "view == azureWorkspace && viewItem == cosmosDBDocumentServerAttached", + "when": "view =~ /azureWorkspace/ && viewItem =~ /treeitem[.]account(?![a-z.\\/])/i", "group": "1@2" }, { + "//": "[Account] Detach Mongo DB account (workspace only)", "command": "azureDatabases.detachDatabaseAccount", - "when": "view == azureWorkspace && viewItem == cosmosDBTableAccountAttached", + "when": "view =~ /azureWorkspace/ && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", "group": "1@2" }, { - "command": "azureDatabases.detachDatabaseAccount", - "when": "view == azureWorkspace && viewItem == postgresServerAttached", + "//": "[Account] Detach Mongo Cluster account (workspace only)", + "command": "command.mongoClusters.removeWorkspaceConnection", + "when": "view =~ /azureWorkspace/ && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", "group": "1@2" }, { - "command": "cosmosDB.connectMongoDB", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == mongoDb", + "//": "[Account] Copy connection string to Cosmos DB account", + "command": "cosmosDB.copyConnectionString", + "when": "view =~ /azure(ResourceGroups|FocusView|Workspace)/ && viewItem =~ /treeitem[.]account(?![a-z.\\/])/i", "group": "2@1" }, { - "command": "cosmosDB.deleteMongoDB", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == mongoDb", - "group": "1@2" - }, - { - "command": "cosmosDB.deleteMongoCollection", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoCollection", - "group": "1@4" - }, - { - "command": "cosmosDB.deleteMongoDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoDocument", - "group": "1@2" - }, - { - "command": "cosmosDB.deleteDocDBCollection", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection", - "group": "1@4" + "//": "[Account] Mongo DB|Cluster Copy connection string", + "command": "cosmosDB.copyConnectionString", + "when": "view =~ /azure(ResourceGroups|FocusView|Workspace)/ && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", + "group": "2@1" }, { - "command": "cosmosDB.viewDocDBCollectionOffer", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection", - "group": "1@5" + "//": "[Account] Mongo DB|Cluster Launch Shell", + "command": "command.mongoClusters.launchShell", + "when": "view =~ /azure(ResourceGroups|FocusView|Workspace)/ && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", + "group": "2@2" }, { - "command": "cosmosDB.viewDocDBDatabaseOffer", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentDatabase", - "group": "1@5" + "//": "[Account] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azureWorkspace/ && viewItem =~ /treeitem[.](mongoCluster|account)(?![a-z.\\/])/i", + "group": "3@1" }, { - "command": "cosmosDB.deleteDocDBDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocument", - "group": "1@2" + "//": "[Database] Create Graph container", + "command": "cosmosDB.createGraph", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](graph)/i", + "group": "1@1" }, { - "command": "cosmosDB.deleteDocDBStoredProcedure", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBStoredProcedure", - "group": "1@2" + "//": "[Database] Create NoSql container", + "command": "cosmosDB.createDocDBCollection", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", + "group": "1@1" }, { - "command": "cosmosDB.executeDocDBStoredProcedure", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBStoredProcedure", + "//": "[Database] Mongo DB|Cluster Create collection", + "command": "command.mongoClusters.createCollection", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@1" }, { - "command": "cosmosDB.deleteDocDBTrigger", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBTrigger", + "//": "[Database] Delete Graph database", + "command": "cosmosDB.deleteGraphDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](graph)/i", "group": "1@2" }, { + "//": "[Database] Delete NoSql database", "command": "cosmosDB.deleteDocDBDatabase", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", "group": "1@2" }, { - "command": "cosmosDB.deleteGraphDatabase", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBGraphDatabase", - "group": "1@2" - }, - { - "command": "postgreSQL.deleteDatabase", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", + "//": "[Database] Mongo DB|Cluster Delete database", + "command": "command.mongoClusters.dropDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@2" }, { - "command": "postgreSQL.deleteTable", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresTable", - "group": "1@2" + "//": "[Database] View NoSql database offer", + "command": "cosmosDB.viewDocDBDatabaseOffer", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", + "group": "1@3" }, { - "command": "postgreSQL.deleteFunction", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunction", - "group": "1@2" + "//": "[Database] Mongo DB|Cluster Launch Shell", + "command": "command.mongoClusters.launchShell", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "2@1" }, { - "command": "postgreSQL.deleteStoredProcedure", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedure", - "group": "1@2" + "//": "[Database] Mongo DB|Cluster Scrapbook Submenu", + "submenu": "azureDatabases.submenus.mongo.database.scrapbook", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "2@2" }, { - "command": "cosmosDB.deleteGraph", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBGraph", - "group": "1@2" + "//": "[Database] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i", + "group": "3@1" }, { - "command": "cosmosDB.attachDatabaseAccount", - "when": "view == azureWorkspace && viewItem =~ /cosmosDBAttachedAccounts(?![a-z])/gi", + "//": "[Container] Open NoSql query editor", + "command": "cosmosDB.openNoSqlQueryEditor", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", "group": "1@1" }, { - "command": "cosmosDB.attachEmulator", - "when": "view == azureWorkspace && viewItem == cosmosDBAttachedAccountsWithEmulator", - "group": "1@2" - }, - { - "command": "cosmosDB.openCollection", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoCollection", + "//": "[Container] Open NoSql query editor (scrapbook)", + "command": "cosmosDB.writeNoSqlQuery", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", "group": "1@2" }, { - "command": "cosmosDB.copyConnectionString", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBMongoServer(?![a-z])/i", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBGraphAccount(?![a-z])/i", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBDocumentServer(?![a-z])/i", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBTableAccount(?![a-z])/i", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view == azureWorkspace && viewItem == cosmosDBMongoServerAttached", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view == azureWorkspace && viewItem == cosmosDBGraphAccountAttached", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view == azureWorkspace && viewItem == cosmosDBDocumentServerAttached", - "group": "2@1" + "//": "[Container] Import NoSql documents", + "command": "cosmosDB.importDocument", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", + "group": "1@3" }, { - "command": "cosmosDB.copyConnectionString", - "when": "view == azureWorkspace && viewItem == cosmosDBTableAccountAttached", - "group": "2@1" + "//": "[Container] Delete NoSql container", + "command": "cosmosDB.deleteDocDBCollection", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", + "group": "1@4" }, { - "command": "postgreSQL.copyConnectionString", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", - "group": "2@1" + "//": "[Container] Delete Graph container", + "command": "cosmosDB.deleteGraph", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](graph)/i", + "group": "1@2" }, { - "command": "azureDatabases.refresh", - "when": "view == azureWorkspace && viewItem == cosmosDBMongoServerAttached", - "group": "3@2" + "//": "[Container] View NoSql container offer", + "command": "cosmosDB.viewDocDBCollectionOffer", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", + "group": "1@5" }, { + "//": "[Container] Refresh", "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i", "group": "2@1" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentDatabase", - "group": "4@1" - }, - { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentsGroup", - "group": "2@1" + "//": "[Collection] Mongo DB|Cluster Open collection", + "command": "command.mongoClusters.containerView.open", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@1" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBStoredProceduresGroup", - "group": "2@1" + "//": "[Collection] Mongo DB|Cluster Create document", + "command": "command.mongoClusters.createDocument", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@2" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBTriggersGroup", + "//": "[Collection] Import Mongo DB|Cluster documents", + "command": "command.mongoClusters.importDocuments", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "2@1" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azureWorkspace/ && viewItem =~ /cosmosDBDocumentServer(?![a-z])/i", - "group": "3@2" - }, - { - "command": "azureDatabases.refresh", - "when": "view =~ /azureWorkspace/ && viewItem =~ /postgresServer(?![a-z])/i", + "//": "[Collection] Mongo DB|Cluster Export documents", + "command": "command.mongoClusters.exportDocuments", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "2@2" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", + "//": "[Collection] Mongo DB|Cluster Drop collection", + "command": "command.mongoClusters.dropCollection", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "3@1" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresTables", - "group": "1@1" - }, - { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunctions", - "group": "2@1" - }, - { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedures", - "group": "2@1" + "//": "[Collection] Mongo DB|Cluster Launch shell", + "command": "command.mongoClusters.launchShell", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "4@1" }, { - "command": "azureDatabases.refresh", - "when": "view == azureWorkspace && viewItem == cosmosDBDocumentServerAttached", - "group": "3@1" + "//": "[Collection] Mongo DB|Cluster Create Scrapbook", + "submenu": "azureDatabases.submenus.mongo.collection.scrapbook", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "4@2" }, { + "//": "[Collection] Refresh", "command": "azureDatabases.refresh", - "when": "view =~ /azureWorkspace/ && viewItem =~ /cosmosDBGraphAccount(?![a-z])/i", - "group": "3@2" + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i", + "group": "5@1" }, { - "command": "azureDatabases.refresh", - "when": "view == azureWorkspace && viewItem == cosmosDBGraphAccountAttached", - "group": "3@1" + "//": "[Stored Procedures] Create Stored Procedure", + "command": "cosmosDB.createDocDBStoredProcedure", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]storedProcedures(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@1" }, { + "//": "[Stored Procedures] Refresh", "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBGraphDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]storedProcedures(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", "group": "2@1" }, { - "command": "azureDatabases.refresh", - "when": "view == azureWorkspace && viewItem == postgresServerAttached", - "group": "2@1" + "//": "[Stored Procedure] Execute", + "command": "cosmosDB.executeDocDBStoredProcedure", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]storedProcedure(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@1" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == mongoDb", - "group": "3@1" + "//": "[Stored Procedure] Delete", + "command": "cosmosDB.deleteDocDBStoredProcedure", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]storedProcedure(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@2" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoCollection", - "group": "4@1" + "//": "[Triggers] Create Trigger", + "command": "cosmosDB.createDocDBTrigger", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]triggers(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@1" }, { + "//": "[Triggers] Refresh", "command": "azureDatabases.refresh", - "when": "view == azureWorkspace && viewItem =~ /^cosmosDBAttachedAccounts(?![a-z])/gi", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]triggers(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", "group": "2@1" }, { - "command": "cosmosDB.importDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoCollection", - "group": "1@3" - }, - { - "command": "postgreSQL.connectDatabase", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", - "group": "1@1" - }, - { - "command": "postgreSQL.createFunctionQuery", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunctions", + "//": "[Trigger] Delete", + "command": "cosmosDB.deleteDocDBTrigger", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]trigger(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", "group": "1@1" }, { - "command": "postgreSQL.createStoredProcedureQuery", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedures", + "//": "[Documents] Open NoSql query editor", + "command": "cosmosDB.openNoSqlQueryEditor", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]documents(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", "group": "1@1" }, { - "command": "command.mongoClusters.dropCollection", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem == mongoClusters.item.collection" + "//": "[Documents] Create NoSql Document", + "command": "cosmosDB.createDocDBDocument", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]documents(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@2" }, { - "command": "command.mongoClusters.dropDatabase", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem == mongoClusters.item.database" + "//": "[Documents] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]documents(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "2@1" }, { - "command": "command.mongoClusters.removeWorkspaceConnection", - "when": "vscodeDatabases.mongoClustersSupportEnabled && view == azureWorkspace && viewItem == mongoClusters.item.mongoCluster" + "//": "[Document] Delete", + "command": "cosmosDB.deleteDocDBDocument", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]document(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@1" }, { - "command": "command.mongoClusters.createCollection", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem == mongoClusters.item.database" + "//": "[Document] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]document(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "2@1" }, { - "command": "command.mongoClusters.createDatabase", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem =~ /mongoClusters.item.mongoCluster/i", + "//": "[Indexes] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]indexes(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@1" }, { - "command": "command.mongoClusters.importDocuments", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem == mongoClusters.item.collection", + "//": "[Index] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]index(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@1" - }, - { - "command": "command.mongoClusters.exportDocuments", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem == mongoClusters.item.collection", - "group": "1@2" - }, - { - "command": "command.mongoClusters.launchShell", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem =~ /mongoClusters.item.(mongoCluster|database|collection)/i", - "group": "2@1" } ], "explorer/context": [ @@ -1331,11 +1272,6 @@ "type": "boolean", "default": true, "description": "Show warning dialog when uploading a document to the cloud." - }, - "cosmosDB.preview.queryEditor": { - "type": "boolean", - "default": true, - "description": "Enable the NoSQL Query Editor." } } } diff --git a/src/AzureDBExperiences.ts b/src/AzureDBExperiences.ts index eed43a810..47fb0da8f 100644 --- a/src/AzureDBExperiences.ts +++ b/src/AzureDBExperiences.ts @@ -5,6 +5,7 @@ import { type DatabaseAccountGetResults } from '@azure/arm-cosmosdb/src/models'; import { type IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; +import { type CosmosDBResource } from './tree/CosmosAccountModel'; import { nonNullProp } from './utils/nonNull'; export enum API { @@ -12,6 +13,7 @@ export enum API { MongoClusters = 'MongoClusters', Graph = 'Graph', Table = 'Table', + Cassandra = 'Cassandra', Core = 'Core', // Now called NoSQL PostgresSingle = 'PostgresSingle', PostgresFlexible = 'PostgresFlexible', @@ -23,7 +25,21 @@ export enum DBAccountKind { GlobalDocumentDB = 'GlobalDocumentDB', } -export type CapabilityName = 'EnableGremlin' | 'EnableTable'; +enum Capability { + EnableGremlin = 'EnableGremlin', + EnableTable = 'EnableTable', + EnableCassandra = 'EnableCassandra', +} + +enum Tag { + Core = 'Core (SQL)', + Mongo = 'Azure Cosmos DB for MongoDB API', + Table = 'Azure Table', + Gremlin = 'Gremlin (graph)', + Cassandra = 'Cassandra', +} + +export type CapabilityName = 'EnableGremlin' | 'EnableTable' | 'EnableCassandra'; export function getExperienceFromApi(api: API): Experience { let info = experiencesMap.get(api); @@ -33,30 +49,49 @@ export function getExperienceFromApi(api: API): Experience { return info; } -export function getExperienceLabel(databaseAccount: DatabaseAccountGetResults): string { - const experience: Experience | undefined = tryGetExperience(databaseAccount); +export function getExperienceLabel(resource: CosmosDBResource | DatabaseAccountGetResults): string { + const experience: Experience | undefined = tryGetExperience(resource); if (experience) { return experience.shortName; } // Must be some new kind of resource that we aren't aware of. Try to get a decent label - const defaultExperience: string = ( - (databaseAccount && databaseAccount.tags && databaseAccount.tags.defaultExperience) - ); - const firstCapability = databaseAccount.capabilities && databaseAccount.capabilities[0]; - const firstCapabilityName = firstCapability?.name?.replace(/^Enable/, ''); - return defaultExperience || firstCapabilityName || nonNullProp(databaseAccount, 'kind'); + const defaultExperience: string = resource?.tags?.defaultExperience ?? ''; + + if ('capabilities' in resource) { + const firstCapability = resource?.capabilities?.[0] ?? {}; + const firstCapabilityName = firstCapability?.name?.replace(/^Enable/, ''); + return firstCapabilityName || nonNullProp(resource, 'kind'); + } + + return defaultExperience || nonNullProp(resource, 'kind'); } -export function tryGetExperience(resource: DatabaseAccountGetResults): Experience | undefined { - // defaultExperience in the resource doesn't really mean anything, we can't depend on its value for determining resource type +export function tryGetExperience(resource: CosmosDBResource | DatabaseAccountGetResults): Experience | undefined { if (resource.kind === DBAccountKind.MongoDB) { return MongoExperience; - } else if (resource.capabilities?.find((cap) => cap.name === 'EnableGremlin')) { - return GremlinExperience; - } else if (resource.capabilities?.find((cap) => cap.name === 'EnableTable')) { - return TableExperience; - } else if (resource.capabilities?.length === 0) { - return CoreExperience; + } + + if ('capabilities' in resource) { + // defaultExperience in the resource doesn't really mean anything, we can't depend on its value for determining resource type + if (resource.capabilities?.find((cap) => cap.name === Capability.EnableGremlin)) { + return GremlinExperience; + } else if (resource.capabilities?.find((cap) => cap.name === Capability.EnableTable)) { + return TableExperience; + } else if (resource.capabilities?.find((cap) => cap.name === Capability.EnableCassandra)) { + return CassandraExperience; + } else if (resource.capabilities?.length === 0) { + return CoreExperience; + } + } else if ('tags' in resource) { + if (resource.tags?.defaultExperience === Tag.Gremlin) { + return GremlinExperience; + } else if (resource.tags?.defaultExperience === Tag.Table) { + return TableExperience; + } else if (resource.tags?.defaultExperience === Tag.Cassandra) { + return CassandraExperience; + } else if (resource.tags?.defaultExperience === Tag.Core) { + return CoreExperience; + } } return undefined; @@ -72,6 +107,9 @@ export interface Experience { shortName: string; description?: string; + // the string used as a telemetry key for a given experience + telemetryName?: string; + // These properties are what the portal actually looks at to determine the difference between APIs kind?: DBAccountKind; capability?: CapabilityName; @@ -120,9 +158,16 @@ export const MongoExperience: Experience = { api: API.MongoDB, longName: 'Cosmos DB for MongoDB', shortName: 'MongoDB', + telemetryName: 'mongo', kind: DBAccountKind.MongoDB, tag: 'Azure Cosmos DB for MongoDB API', } as const; +export const MongoClustersExprience: Experience = { + api: API.MongoClusters, + longName: 'Cosmos DB for MongoDB (vCore)', + shortName: 'MongoDB (vCore)', + telemetryName: 'mongoClusters', +} as const; export const TableExperience: Experience = { api: API.Table, longName: 'Cosmos DB for Table', @@ -140,12 +185,20 @@ export const GremlinExperience: Experience = { capability: 'EnableGremlin', tag: 'Gremlin (graph)', } as const; -const PostgresSingleExperience: Experience = { +export const CassandraExperience: Experience = { + api: API.Cassandra, + longName: 'Cosmos DB for Cassandra', + shortName: 'Cassandra', + kind: DBAccountKind.GlobalDocumentDB, + capability: 'EnableCassandra', + tag: 'Cassandra', +}; +export const PostgresSingleExperience: Experience = { api: API.PostgresSingle, longName: 'PostgreSQL Single Server', shortName: 'PostgreSQLSingle', }; -const PostgresFlexibleExperience: Experience = { +export const PostgresFlexibleExperience: Experience = { api: API.PostgresFlexible, longName: 'PostgreSQL Flexible Server', shortName: 'PostgreSQLFlexible', diff --git a/src/DatabasesFileSystem.ts b/src/DatabasesFileSystem.ts index e58aa0359..830e6956f 100644 --- a/src/DatabasesFileSystem.ts +++ b/src/DatabasesFileSystem.ts @@ -13,8 +13,8 @@ import { import { FileType, workspace, type FileStat, type MessageItem, type Uri } from 'vscode'; import { FileChangeType } from 'vscode-languageclient'; import { ext } from './extensionVariables'; +import { SettingsService } from './services/SettingsService'; import { localize } from './utils/localize'; -import { getWorkspaceSetting, updateGlobalSetting } from './utils/settingUtils'; import { getNodeEditorLabel } from './utils/vscodeUtils'; export interface IEditableTreeItem extends AzExtTreeItem { @@ -50,7 +50,7 @@ export class DatabasesFileSystem extends AzExtTreeFileSystem // NOTE: Using "cosmosDB" instead of "azureDatabases" here for the sake of backwards compatibility. If/when this file system adds support for non-cosmosdb items, we should consider changing this to "azureDatabases" const prefix: string = 'cosmosDB'; const nodeEditorLabel: string = getNodeEditorLabel(node); - if (this._showSaveConfirmation && getWorkspaceSetting(showSavePromptKey, undefined, prefix)) { + if (this._showSaveConfirmation && SettingsService.getSetting(showSavePromptKey, undefined, prefix)) { const message: string = localize( 'saveConfirmation', 'Saving "{0}" will update the entity "{1}" to the cloud.', @@ -65,7 +65,7 @@ export class DatabasesFileSystem extends AzExtTreeFileSystem DialogResponses.dontUpload, ); if (result === DialogResponses.alwaysUpload) { - await updateGlobalSetting(showSavePromptKey, false, prefix); + await SettingsService.updateGlobalSetting(showSavePromptKey, false, prefix); } else if (result === DialogResponses.dontUpload) { throw new UserCancelledError('dontUpload'); } diff --git a/src/commands/account/copyConnectionString.ts b/src/commands/account/copyConnectionString.ts new file mode 100644 index 000000000..a44efaf33 --- /dev/null +++ b/src/commands/account/copyConnectionString.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { ext } from '../../extensionVariables'; +import { MongoClusterItemBase } from '../../mongoClusters/tree/MongoClusterItemBase'; +import { type CosmosDBAttachedAccountsResourceItem } from '../../tree/attached/CosmosDBAttachedAccountsResourceItem'; +import { DocumentDBAccountAttachedResourceItem } from '../../tree/docdb/DocumentDBAccountAttachedResourceItem'; +import { DocumentDBAccountResourceItem } from '../../tree/docdb/DocumentDBAccountResourceItem'; +import { MongoAccountResourceItem } from '../../tree/mongo/MongoAccountResourceItem'; +import { localize } from '../../utils/localize'; + +export async function copyConnectionString( + context: IActionContext, + node?: + | DocumentDBAccountAttachedResourceItem // NoSQL and other DocuemntDB accounts (except Mongo RU) in the resource area + | CosmosDBAttachedAccountsResourceItem // NoSQL and other DocumentDB accounts (except Mongo RU) in the workspace area + | MongoAccountResourceItem // Mongo (RU), WIP/work in progress, currently only the resource area + | MongoClusterItemBase, // Mongo Cluster (vCore), in buth, the resource and in the workspace area +): Promise { + if (!node) { + throw new Error('WIP: No node selected.'); // wip, wire up a picker + // node = await ext.rgApi.pickAppResource(context, { + // filter: [cosmosMongoFilter, cosmosTableFilter, cosmosGremlinFilter, sqlFilter], + // }); + } + + const connectionString = await ext.state.runWithTemporaryDescription( + node.id, + localize('copyConnectionString.working', 'Working...'), + async () => { + if (node instanceof DocumentDBAccountResourceItem) { + context.telemetry.properties.experience = node.experience.api; + return await node.getConnectionString(); + } + + if (node instanceof MongoAccountResourceItem) { + context.telemetry.properties.experience = node.experience.api; + return node.discoverConnectionString(); + } + + // TODO: revisit when updating "Attached Accounts" storage and migration: runWithTemporaryDescription was not showing the temporary description + // most likely due to a mismatching node.id. + if (node instanceof DocumentDBAccountAttachedResourceItem) { + context.telemetry.properties.experience = node.experience.api; + return node.account.connectionString; + } + + if (node instanceof MongoClusterItemBase) { + context.telemetry.properties.experience = node.mongoCluster.dbExperience?.api; + return node.discoverConnectionString(); + } + + return undefined; + }, + ); + + if (!connectionString) { + void vscode.window.showErrorMessage( + localize( + 'copyConnectionString.noConnectionString', + 'Failed to extract the connection string from the selected account.', + ), + ); + } else { + await vscode.env.clipboard.writeText(connectionString); + void vscode.window.showInformationMessage( + localize('copyConnectionString.success', 'The connection string has been copied to the clipboard'), + ); + } +} diff --git a/src/commands/account/registerAccountCommands.ts b/src/commands/account/registerAccountCommands.ts new file mode 100644 index 000000000..7492ec272 --- /dev/null +++ b/src/commands/account/registerAccountCommands.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + type AzExtTreeItem, + type IActionContext, + type ITreeItemPickerContext, + registerCommandWithTreeNodeUnwrapping, +} from '@microsoft/vscode-azext-utils'; +import { platform } from 'os'; +import vscode from 'vscode'; +import { cosmosGremlinFilter, cosmosMongoFilter, cosmosTableFilter, sqlFilter } from '../../constants'; +import { DocDBAccountTreeItem } from '../../docdb/tree/DocDBAccountTreeItem'; +import { ext } from '../../extensionVariables'; +import { GraphAccountTreeItem } from '../../graph/tree/GraphAccountTreeItem'; +import { setConnectedNode } from '../../mongo/setConnectedNode'; +import { MongoAccountTreeItem } from '../../mongo/tree/MongoAccountTreeItem'; +import { TableAccountTreeItem } from '../../table/tree/TableAccountTreeItem'; +import { AttachedAccountSuffix } from '../../tree/AttachedAccountsTreeItem'; +import { SubscriptionTreeItem } from '../../tree/SubscriptionTreeItem'; +import { localize } from '../../utils/localize'; +import { deleteDatabaseAccount } from '../deleteDatabaseAccount/deleteDatabaseAccount'; +import { copyConnectionString } from './copyConnectionString'; + +const cosmosDBTopLevelContextValues: string[] = [ + GraphAccountTreeItem.contextValue, + DocDBAccountTreeItem.contextValue, + TableAccountTreeItem.contextValue, + MongoAccountTreeItem.contextValue, +]; + +export function registerAccountCommands() { + registerCommandWithTreeNodeUnwrapping('azureDatabases.createServer', createServer); + registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteAccount', deleteAccount); + registerCommandWithTreeNodeUnwrapping('cosmosDB.attachDatabaseAccount', async (actionContext: IActionContext) => { + await ext.attachedAccountsNode.attachNewAccount(actionContext); + await ext.rgApi.workspaceResourceTree.refresh(actionContext, ext.attachedAccountsNode); + }); + registerCommandWithTreeNodeUnwrapping('cosmosDB.attachEmulator', async (actionContext: IActionContext) => { + if (platform() !== 'win32') { + actionContext.errorHandling.suppressReportIssue = true; + throw new Error(localize('emulatorNotSupported', 'The Cosmos DB emulator is only supported on Windows.')); + } + + await ext.attachedAccountsNode.attachEmulator(actionContext); + await ext.rgApi.workspaceResourceTree.refresh(actionContext, ext.attachedAccountsNode); + }); + registerCommandWithTreeNodeUnwrapping( + 'azureDatabases.detachDatabaseAccount', + async (actionContext: IActionContext & ITreeItemPickerContext, node?: AzExtTreeItem) => { + const children = await ext.attachedAccountsNode.loadAllChildren(actionContext); + if (children.length < 2) { + const message = localize('noAttachedAccounts', 'There are no Attached Accounts.'); + void vscode.window.showInformationMessage(message); + } else { + if (!node) { + node = await ext.rgApi.workspaceResourceTree.showTreeItemPicker( + cosmosDBTopLevelContextValues.map((val: string) => (val += AttachedAccountSuffix)), + actionContext, + ); + } + if (node instanceof MongoAccountTreeItem) { + if (ext.connectedMongoDB && node.fullId === ext.connectedMongoDB.parent.fullId) { + setConnectedNode(undefined); + await node.refresh(actionContext); + } + } + await ext.attachedAccountsNode.detach(node); + await ext.rgApi.workspaceResourceTree.refresh(actionContext, ext.attachedAccountsNode); + } + }, + ); + registerCommandWithTreeNodeUnwrapping('cosmosDB.copyConnectionString', copyConnectionString); +} + +export async function createServer(context: IActionContext, node?: SubscriptionTreeItem): Promise { + if (!node) { + node = await ext.rgApi.appResourceTree.showTreeItemPicker( + SubscriptionTreeItem.contextValue, + context, + ); + } + + await SubscriptionTreeItem.createChild(context, node); +} + +export async function deleteAccount(context: IActionContext, node?: AzExtTreeItem): Promise { + const suppressCreateContext: ITreeItemPickerContext = context; + suppressCreateContext.suppressCreatePick = true; + if (!node) { + node = await ext.rgApi.pickAppResource(context, { + filter: [cosmosMongoFilter, cosmosTableFilter, cosmosGremlinFilter, sqlFilter], + }); + } + + await deleteDatabaseAccount(context, node, false); +} diff --git a/src/commands/api/DatabaseTreeItemInternal.ts b/src/commands/api/DatabaseTreeItemInternal.ts index d104bdf45..649ab1d6e 100644 --- a/src/commands/api/DatabaseTreeItemInternal.ts +++ b/src/commands/api/DatabaseTreeItemInternal.ts @@ -12,7 +12,6 @@ import { type DocDBAccountTreeItemBase } from '../../docdb/tree/DocDBAccountTree import { type DocDBDatabaseTreeItemBase } from '../../docdb/tree/DocDBDatabaseTreeItemBase'; import { ext } from '../../extensionVariables'; import { type MongoAccountTreeItem } from '../../mongo/tree/MongoAccountTreeItem'; -import { type MongoDatabaseTreeItem } from '../../mongo/tree/MongoDatabaseTreeItem'; import { type ParsedConnectionString } from '../../ParsedConnectionString'; import { type PostgresDatabaseTreeItem } from '../../postgres/tree/PostgresDatabaseTreeItem'; import { type PostgresServerTreeItem } from '../../postgres/tree/PostgresServerTreeItem'; @@ -26,8 +25,8 @@ export class DatabaseTreeItemInternal extends DatabaseAccountTreeItemInternal im constructor( parsedCS: ParsedConnectionString, databaseName: string, - accountNode?: MongoAccountTreeItem | DocDBAccountTreeItemBase | PostgresServerTreeItem, - dbNode?: MongoDatabaseTreeItem | DocDBDatabaseTreeItemBase | PostgresDatabaseTreeItem, + accountNode?: DocDBAccountTreeItemBase | PostgresServerTreeItem, + dbNode?: DocDBDatabaseTreeItemBase | PostgresDatabaseTreeItem, ) { super(parsedCS, accountNode); this.databaseName = databaseName; diff --git a/src/commands/api/findTreeItem.ts b/src/commands/api/findTreeItem.ts index ec2624d15..76be41ee3 100644 --- a/src/commands/api/findTreeItem.ts +++ b/src/commands/api/findTreeItem.ts @@ -13,8 +13,6 @@ import { DocDBAccountTreeItemBase } from '../../docdb/tree/DocDBAccountTreeItemB import { DocDBDatabaseTreeItemBase } from '../../docdb/tree/DocDBDatabaseTreeItemBase'; import { ext } from '../../extensionVariables'; import { parseMongoConnectionString } from '../../mongo/mongoConnectionStrings'; -import { MongoAccountTreeItem } from '../../mongo/tree/MongoAccountTreeItem'; -import { MongoDatabaseTreeItem } from '../../mongo/tree/MongoDatabaseTreeItem'; import { type ParsedConnectionString } from '../../ParsedConnectionString'; import { createPostgresConnectionString, @@ -29,6 +27,10 @@ import { cacheTreeItem, tryGetTreeItemFromCache } from './apiCache'; import { DatabaseAccountTreeItemInternal } from './DatabaseAccountTreeItemInternal'; import { DatabaseTreeItemInternal } from './DatabaseTreeItemInternal'; +/** + * TODO: This needs a rewrite to match v2 + */ + export async function findTreeItem( query: TreeItemQuery, ): Promise { @@ -115,9 +117,10 @@ async function searchDbAccounts( } let actual: ParsedConnectionString; - if (dbAccount instanceof MongoAccountTreeItem) { - actual = await parseMongoConnectionString(dbAccount.connectionString); - } else if (dbAccount instanceof DocDBAccountTreeItemBase) { + // if (dbAccount instanceof MongoAccountTreeItem) { + // actual = await parseMongoConnectionString(dbAccount.connectionString); + // } else + if (dbAccount instanceof DocDBAccountTreeItemBase) { actual = parseDocDBConnectionString(dbAccount.connectionString); } else if (dbAccount instanceof PostgresServerTreeItem) { actual = dbAccount.partialConnectionString; @@ -129,10 +132,7 @@ async function searchDbAccounts( if (expected.databaseName) { const dbs = await dbAccount.getCachedChildren(context); for (const db of dbs) { - if ( - (db instanceof MongoDatabaseTreeItem || db instanceof DocDBDatabaseTreeItemBase) && - expected.databaseName === db.databaseName - ) { + if (db instanceof DocDBDatabaseTreeItemBase && expected.databaseName === db.databaseName) { return new DatabaseTreeItemInternal(expected, expected.databaseName, dbAccount, db); } if ( diff --git a/src/commands/api/pickTreeItem.ts b/src/commands/api/pickTreeItem.ts index 3abf8d7b4..266942af2 100644 --- a/src/commands/api/pickTreeItem.ts +++ b/src/commands/api/pickTreeItem.ts @@ -12,9 +12,7 @@ import { DocDBDatabaseTreeItem } from '../../docdb/tree/DocDBDatabaseTreeItem'; import { DocDBDatabaseTreeItemBase } from '../../docdb/tree/DocDBDatabaseTreeItemBase'; import { ext } from '../../extensionVariables'; import { GraphDatabaseTreeItem } from '../../graph/tree/GraphDatabaseTreeItem'; -import { parseMongoConnectionString } from '../../mongo/mongoConnectionStrings'; -import { MongoAccountTreeItem } from '../../mongo/tree/MongoAccountTreeItem'; -import { MongoDatabaseTreeItem } from '../../mongo/tree/MongoDatabaseTreeItem'; +import { type MongoAccountTreeItem } from '../../mongo/tree/MongoAccountTreeItem'; import { type ParsedConnectionString } from '../../ParsedConnectionString'; import { PostgresDatabaseTreeItem } from '../../postgres/tree/PostgresDatabaseTreeItem'; import { PostgresServerTreeItem } from '../../postgres/tree/PostgresServerTreeItem'; @@ -29,16 +27,17 @@ import { cacheTreeItem } from './apiCache'; import { DatabaseAccountTreeItemInternal } from './DatabaseAccountTreeItemInternal'; import { DatabaseTreeItemInternal } from './DatabaseTreeItemInternal'; +/** + * TODO: This needs a rewrite to match v2 + */ + const databaseContextValues = [ - MongoDatabaseTreeItem.contextValue, DocDBDatabaseTreeItem.contextValue, GraphDatabaseTreeItem.contextValue, PostgresDatabaseTreeItem.contextValue, ]; function getDatabaseContextValue(apiType: AzureDatabasesApiType): string { switch (apiType) { - case 'Mongo': - return MongoDatabaseTreeItem.contextValue; case 'SQL': return DocDBDatabaseTreeItem.contextValue; case 'Graph': @@ -76,20 +75,17 @@ export async function pickTreeItem( let parsedCS: ParsedConnectionString; let accountNode: MongoAccountTreeItem | DocDBAccountTreeItemBase | PostgresServerTreeItem; - let databaseNode: MongoDatabaseTreeItem | DocDBDatabaseTreeItemBase | PostgresDatabaseTreeItem | undefined; - if (pickedItem instanceof MongoAccountTreeItem) { - parsedCS = await parseMongoConnectionString(pickedItem.connectionString); - accountNode = pickedItem; - } else if (pickedItem instanceof DocDBAccountTreeItemBase) { + let databaseNode: DocDBDatabaseTreeItemBase | PostgresDatabaseTreeItem | undefined; + // if (pickedItem instanceof MongoAccountTreeItem) { + // parsedCS = await parseMongoConnectionString(pickedItem.connectionString); + // accountNode = pickedItem; + // } else + if (pickedItem instanceof DocDBAccountTreeItemBase) { parsedCS = parseDocDBConnectionString(pickedItem.connectionString); accountNode = pickedItem; } else if (pickedItem instanceof PostgresServerTreeItem) { parsedCS = await pickedItem.getFullConnectionString(); accountNode = pickedItem; - } else if (pickedItem instanceof MongoDatabaseTreeItem) { - parsedCS = await parseMongoConnectionString(pickedItem.connectionString); - accountNode = pickedItem.parent; - databaseNode = pickedItem; } else if (pickedItem instanceof DocDBDatabaseTreeItemBase) { parsedCS = parseDocDBConnectionString(pickedItem.connectionString); accountNode = pickedItem.parent; diff --git a/src/commands/deleteDatabaseAccount/DatabaseAccountDeleteStep.ts b/src/commands/deleteDatabaseAccount/DatabaseAccountDeleteStep.ts index 9f4940dd6..bf932aabc 100644 --- a/src/commands/deleteDatabaseAccount/DatabaseAccountDeleteStep.ts +++ b/src/commands/deleteDatabaseAccount/DatabaseAccountDeleteStep.ts @@ -3,17 +3,36 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; -import { type IDeleteWizardContext } from './IDeleteWizardContext'; +import { AzExtTreeItem, AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { ext } from '../../extensionVariables'; +import { MongoClusterResourceItem } from '../../mongoClusters/tree/MongoClusterResourceItem'; +import { CosmosAccountResourceItemBase } from '../../tree/CosmosAccountResourceItemBase'; +import { type DeleteWizardContext } from './DeleteWizardContext'; +import { deleteCosmosDBAccount } from './deleteCosmosDBAccount'; +import { deleteMongoClustersAccount } from './deleteMongoClustersAccount'; -export class DatabaseAccountDeleteStep extends AzureWizardExecuteStep { +export class DatabaseAccountDeleteStep extends AzureWizardExecuteStep { public priority: number = 100; - public async execute(context: IDeleteWizardContext): Promise { - await context.node.deleteTreeItem(context); + public async execute(context: DeleteWizardContext): Promise { + if (context.node instanceof AzExtTreeItem) { + await context.node.deleteTreeItem(context); + } else if (context.node instanceof CosmosAccountResourceItemBase) { + await ext.state.showDeleting(context.node.id, () => + deleteCosmosDBAccount(context, context.node as CosmosAccountResourceItemBase), + ); + ext.cosmosDBBranchDataProvider.refresh(); + } else if (context.node instanceof MongoClusterResourceItem) { + await ext.state.showDeleting(context.node.id, () => + deleteMongoClustersAccount(context, context.node as MongoClusterResourceItem), + ); + ext.mongoClustersBranchDataProvider.refresh(); + } else { + throw new Error('Unexpected node type'); + } } - public shouldExecute(_wizardContext: IDeleteWizardContext): boolean { + public shouldExecute(_wizardContext: DeleteWizardContext): boolean { return true; } } diff --git a/src/commands/deleteDatabaseAccount/IDeleteWizardContext.ts b/src/commands/deleteDatabaseAccount/DeleteWizardContext.ts similarity index 63% rename from src/commands/deleteDatabaseAccount/IDeleteWizardContext.ts rename to src/commands/deleteDatabaseAccount/DeleteWizardContext.ts index a394ab799..9bee2832f 100644 --- a/src/commands/deleteDatabaseAccount/IDeleteWizardContext.ts +++ b/src/commands/deleteDatabaseAccount/DeleteWizardContext.ts @@ -9,9 +9,11 @@ import { type IActionContext, type ISubscriptionContext, } from '@microsoft/vscode-azext-utils'; +import { type MongoClusterResourceItem } from '../../mongoClusters/tree/MongoClusterResourceItem'; +import { type CosmosAccountResourceItemBase } from '../../tree/CosmosAccountResourceItemBase'; -export interface IDeleteWizardContext extends IActionContext, ExecuteActivityContext { - node: AzExtTreeItem; +export interface DeleteWizardContext extends IActionContext, ExecuteActivityContext { + node: AzExtTreeItem | CosmosAccountResourceItemBase | MongoClusterResourceItem; deletePostgres: boolean; resourceGroupToDelete?: string; subscription: ISubscriptionContext; diff --git a/src/commands/deleteDatabaseAccount/deleteCosmosDBAccount.ts b/src/commands/deleteDatabaseAccount/deleteCosmosDBAccount.ts index bf6f69c41..d00055341 100644 --- a/src/commands/deleteDatabaseAccount/deleteCosmosDBAccount.ts +++ b/src/commands/deleteDatabaseAccount/deleteCosmosDBAccount.ts @@ -5,18 +5,43 @@ import { type CosmosDBManagementClient } from '@azure/arm-cosmosdb'; import { getResourceGroupFromId } from '@microsoft/vscode-azext-azureutils'; -import { type AzExtTreeItem } from '@microsoft/vscode-azext-utils'; +import { AzExtTreeItem, createSubscriptionContext } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; import * as vscode from 'vscode'; import { ext } from '../../extensionVariables'; +import { CosmosAccountResourceItemBase } from '../../tree/CosmosAccountResourceItemBase'; import { createCosmosDBClient } from '../../utils/azureClients'; import { getDatabaseAccountNameFromId } from '../../utils/azureUtils'; import { localize } from '../../utils/localize'; -import { type IDeleteWizardContext } from './IDeleteWizardContext'; +import { type DeleteWizardContext } from './DeleteWizardContext'; + +export async function deleteCosmosDBAccount( + context: DeleteWizardContext, + node: AzExtTreeItem | CosmosAccountResourceItemBase, +): Promise { + let client: CosmosDBManagementClient; + let resourceGroup: string; + let accountName: string; + + if (node instanceof AzExtTreeItem) { + client = await createCosmosDBClient([context, node.subscription]); + resourceGroup = getResourceGroupFromId(node.fullId); + accountName = getDatabaseAccountNameFromId(node.fullId); + } else if (node instanceof CosmosAccountResourceItemBase) { + // Not all CosmosAccountResourceItemBase instances have a subscription property (attached account does not), + // so we need to create a subscription context + if (!('subscription' in node.account)) { + throw new Error('Subscription is required to delete an account.'); + } + + const subscriptionContext = createSubscriptionContext(node.account.subscription as AzureSubscription); + client = await createCosmosDBClient([context, subscriptionContext]); + resourceGroup = getResourceGroupFromId(node.account.id); + accountName = node.account.name; + } else { + throw new Error('Unexpected node type'); + } -export async function deleteCosmosDBAccount(context: IDeleteWizardContext, node: AzExtTreeItem): Promise { - const client: CosmosDBManagementClient = await createCosmosDBClient([context, node.subscription]); - const resourceGroup: string = getResourceGroupFromId(node.fullId); - const accountName: string = getDatabaseAccountNameFromId(node.fullId); const deletePromise = client.databaseAccounts.beginDeleteAndWait(resourceGroup, accountName); if (!context.suppressNotification) { const deletingMessage: string = `Deleting account "${accountName}"...`; diff --git a/src/commands/deleteDatabaseAccount/deleteDatabaseAccount.ts b/src/commands/deleteDatabaseAccount/deleteDatabaseAccount.ts index bf518aafb..7bb05d709 100644 --- a/src/commands/deleteDatabaseAccount/deleteDatabaseAccount.ts +++ b/src/commands/deleteDatabaseAccount/deleteDatabaseAccount.ts @@ -4,38 +4,65 @@ *--------------------------------------------------------------------------------------------*/ import { + AzExtTreeItem, AzureWizard, + createSubscriptionContext, DeleteConfirmationStep, - type AzExtTreeItem, type IActionContext, + type ISubscriptionContext, } from '@microsoft/vscode-azext-utils'; -import { createActivityContext } from '../../utils/activityUtils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import { MongoClusterResourceItem } from '../../mongoClusters/tree/MongoClusterResourceItem'; +import { CosmosAccountResourceItemBase } from '../../tree/CosmosAccountResourceItemBase'; +import { createActivityContextV2 } from '../../utils/activityUtils'; import { localize } from '../../utils/localize'; import { DatabaseAccountDeleteStep } from './DatabaseAccountDeleteStep'; -import { type IDeleteWizardContext } from './IDeleteWizardContext'; +import { type DeleteWizardContext } from './DeleteWizardContext'; export async function deleteDatabaseAccount( context: IActionContext, - node: AzExtTreeItem, + node: AzExtTreeItem | CosmosAccountResourceItemBase | MongoClusterResourceItem, isPostgres: boolean = false, ): Promise { - const wizardContext: IDeleteWizardContext = Object.assign(context, { + let subscription: ISubscriptionContext; + let accountName: string; + if (node instanceof AzExtTreeItem) { + subscription = node.subscription; + accountName = node.label; + } else if (node instanceof CosmosAccountResourceItemBase && 'subscription' in node.account) { + subscription = createSubscriptionContext(node.account.subscription as AzureSubscription); + accountName = node.account.name; + } else if (node instanceof MongoClusterResourceItem) { + subscription = createSubscriptionContext(node.subscription); + accountName = node.mongoCluster.name; + } else { + // Not all CosmosAccountResourceItemBase instances have a subscription property (attached account does not), + // so we need to create a subscription context + throw new Error('Subscription is required to delete an account.'); + } + + const activityContext = await createActivityContextV2(); + const wizardContext: DeleteWizardContext = Object.assign(context, { node, deletePostgres: isPostgres, - subscription: node.subscription, - ...(await createActivityContext()), + subscription: subscription, + ...activityContext, }); - const title = wizardContext.deletePostgres - ? localize('deletePoSer', 'Delete Postgres Server "{0}"', node.label) - : localize('deleteDbAcc', 'Delete Database Account "{0}"', node.label); + const title = isPostgres + ? localize('deletePoSer', 'Delete Postgres Server "{0}"', accountName) + : localize('deleteDbAcc', 'Delete Database Account "{0}"', accountName); - const confirmationMessage = wizardContext.deletePostgres - ? localize('deleteAccountConfirm', 'Are you sure you want to delete server "{0}" and its contents?', node.label) + const confirmationMessage = isPostgres + ? localize( + 'deleteAccountConfirm', + 'Are you sure you want to delete server "{0}" and its contents?', + accountName, + ) : localize( 'deleteAccountConfirm', 'Are you sure you want to delete account "{0}" and its contents?', - node.label, + accountName, ); const wizard = new AzureWizard(wizardContext, { diff --git a/src/commands/deleteDatabaseAccount/deleteMongoClustersAccount.ts b/src/commands/deleteDatabaseAccount/deleteMongoClustersAccount.ts new file mode 100644 index 000000000..bdf4dfedc --- /dev/null +++ b/src/commands/deleteDatabaseAccount/deleteMongoClustersAccount.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ext } from '../../extensionVariables'; +import { type MongoClusterResourceItem } from '../../mongoClusters/tree/MongoClusterResourceItem'; +import { createMongoClustersManagementClient } from '../../utils/azureClients'; +import { localize } from '../../utils/localize'; +import { type DeleteWizardContext } from './DeleteWizardContext'; + +export async function deleteMongoClustersAccount( + context: DeleteWizardContext, + node: MongoClusterResourceItem, +): Promise { + const client = createMongoClustersManagementClient(context, node.subscription); + const resourceGroup = node.mongoCluster.resourceGroup as string; + const accountName = node.mongoCluster.name; + + const deletePromise = (await client).mongoClusters.beginDeleteAndWait(resourceGroup, accountName); + if (!context.suppressNotification) { + const deletingMessage: string = `Deleting account "${accountName}"...`; + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: deletingMessage }, + async () => { + await deletePromise; + const deleteMessage: string = localize( + 'deleteAccountMsg', + `Successfully deleted account "{0}".`, + accountName, + ); + void vscode.window.showInformationMessage(deleteMessage); + ext.outputChannel.appendLog(deleteMessage); + }, + ); + } else { + await deletePromise; + } +} diff --git a/src/commands/importDocuments.ts b/src/commands/importDocuments.ts index 9ee006e94..108418158 100644 --- a/src/commands/importDocuments.ts +++ b/src/commands/importDocuments.ts @@ -19,7 +19,7 @@ import { getRootPath } from '../utils/workspacUtils'; export async function importDocuments( context: IActionContext, uris: vscode.Uri[] | undefined, - collectionNode: MongoCollectionTreeItem | DocDBCollectionTreeItem | CollectionItem | undefined, + collectionNode: DocDBCollectionTreeItem | CollectionItem | undefined, ): Promise { if (!uris) { uris = await askForDocuments(context); @@ -39,9 +39,9 @@ export async function importDocuments( ext.outputChannel.show(); } if (!collectionNode) { - collectionNode = await ext.rgApi.pickAppResource(context, { + collectionNode = await ext.rgApi.pickAppResource(context, { filter: [cosmosMongoFilter, sqlFilter], - expectedChildContextValue: [MongoCollectionTreeItem.contextValue, DocDBCollectionTreeItem.contextValue], + expectedChildContextValue: [DocDBCollectionTreeItem.contextValue], }); } diff --git a/src/constants.ts b/src/constants.ts index 97c59147a..0ed837df2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -153,3 +153,5 @@ export const postgresFlexibleFilter = { export const postgresSingleFilter = { type: 'Microsoft.DBForPostgreSQL/servers', }; + +export const DocumentDBHiddenFields: string[] = ['_rid', '_self', '_etag', '_attachments', '_ts']; diff --git a/src/mongo/commands/openMongoCollection.ts b/src/docdb/commands/loadMore.ts similarity index 54% rename from src/mongo/commands/openMongoCollection.ts rename to src/docdb/commands/loadMore.ts index b97f32c82..1e6ec47fc 100644 --- a/src/mongo/commands/openMongoCollection.ts +++ b/src/docdb/commands/loadMore.ts @@ -5,12 +5,14 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import { ext } from '../../extensionVariables'; -import { MongoCollectionTreeItem } from '../tree/MongoCollectionTreeItem'; -import { pickMongo } from './pickMongo'; -export async function openMongoCollection(context: IActionContext, node?: MongoCollectionTreeItem): Promise { - if (!node) { - node = await pickMongo(context, MongoCollectionTreeItem.contextValue); +export async function loadMore( + context: IActionContext, + nodeId: string, + loadMoreFn: (context: IActionContext) => Promise | undefined, +): Promise { + if (loadMoreFn) { + await loadMoreFn(context); + ext.state.notifyChildrenChanged(nodeId); } - await ext.fileSystem.showTextDocument(node); } diff --git a/src/docdb/registerDocDBCommands.ts b/src/docdb/registerDocDBCommands.ts index e7df94814..2be60dadc 100644 --- a/src/docdb/registerDocDBCommands.ts +++ b/src/docdb/registerDocDBCommands.ts @@ -21,6 +21,7 @@ import { deleteDocDBTrigger } from './commands/deleteDocDBTrigger'; import { executeDocDBStoredProcedure } from './commands/executeDocDBStoredProcedure'; import { executeNoSqlQuery } from './commands/executeNoSqlQuery'; import { getNoSqlQueryPlan } from './commands/getNoSqlQueryPlan'; +import { loadMore } from './commands/loadMore'; import { openNoSqlQueryEditor } from './commands/openNoSqlQueryEditor'; import { openStoredProcedure } from './commands/openStoredProcedure'; import { openTrigger } from './commands/openTrigger'; @@ -35,6 +36,7 @@ export function registerDocDBCommands(): void { ext.noSqlCodeLensProvider = new NoSqlCodeLensProvider(); ext.context.subscriptions.push(languages.registerCodeLensProvider(nosqlLanguageId, ext.noSqlCodeLensProvider)); + registerCommand('cosmosDB.loadMore', loadMore); registerCommand('cosmosDB.connectNoSqlContainer', connectNoSqlContainer); registerCommand('cosmosDB.executeNoSqlQuery', executeNoSqlQuery); registerCommand('cosmosDB.getNoSqlQueryPlan', getNoSqlQueryPlan); diff --git a/src/docdb/tree/DocDBAccountTreeItemBase.ts b/src/docdb/tree/DocDBAccountTreeItemBase.ts index 716da8e40..0e69825cf 100644 --- a/src/docdb/tree/DocDBAccountTreeItemBase.ts +++ b/src/docdb/tree/DocDBAccountTreeItemBase.ts @@ -21,7 +21,7 @@ import { } from '@microsoft/vscode-azext-utils'; import type * as vscode from 'vscode'; import { API } from '../../AzureDBExperiences'; -import { type IDeleteWizardContext } from '../../commands/deleteDatabaseAccount/IDeleteWizardContext'; +import { type DeleteWizardContext } from '../../commands/deleteDatabaseAccount/DeleteWizardContext'; import { deleteCosmosDBAccount } from '../../commands/deleteDatabaseAccount/deleteCosmosDBAccount'; import { getThemeAgnosticIconPath, SERVERLESS_CAPABILITY_NAME } from '../../constants'; import { nonNullProp } from '../../utils/nonNull'; @@ -152,7 +152,7 @@ export abstract class DocDBAccountTreeItemBase extends DocDBTreeItemBase { + public async deleteTreeItemImpl(context: DeleteWizardContext): Promise { await deleteCosmosDBAccount(context, this); } } diff --git a/src/docdb/utils/rbacUtils.ts b/src/docdb/utils/rbacUtils.ts index fd3317ee7..fcd72dd65 100644 --- a/src/docdb/utils/rbacUtils.ts +++ b/src/docdb/utils/rbacUtils.ts @@ -7,10 +7,12 @@ import { type SqlRoleAssignmentCreateUpdateParameters } from '@azure/arm-cosmosd import { getResourceGroupFromId } from '@microsoft/vscode-azext-azureutils'; import { callWithTelemetryAndErrorHandling, + createSubscriptionContext, type IActionContext, type IAzureMessageOptions, type ISubscriptionContext, } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; import { randomUUID } from 'crypto'; import * as vscode from 'vscode'; import { createCosmosDBClient } from '../../utils/azureClients'; @@ -46,6 +48,39 @@ export async function ensureRbacPermission(docDbItem: DocDBAccountTreeItemBase, ); } +export async function ensureRbacPermissionV2( + fullId: string, + subscription: AzureSubscription, + principalId: string, +): Promise { + return ( + (await callWithTelemetryAndErrorHandling('cosmosDB.addMissingRbacRole', async (context: IActionContext) => { + context.errorHandling.suppressDisplay = false; + context.errorHandling.rethrow = false; + + const subscriptionContext = createSubscriptionContext(subscription); + const accountName: string = getDatabaseAccountNameFromId(fullId); + if (await askForRbacPermissions(accountName, subscriptionContext.subscriptionDisplayName, context)) { + context.telemetry.properties.lastStep = 'addRbacContributorPermission'; + const resourceGroup: string = getResourceGroupFromId(fullId); + const start: number = Date.now(); + await addRbacContributorPermission( + accountName, + principalId, + resourceGroup, + context, + subscriptionContext, + ); + //send duration of the previous call (in seconds) in addition to the duration of the whole event including user prompt + context.telemetry.measurements['createRoleAssignment'] = (Date.now() - start) / 1000; + + return true; + } + return false; + })) ?? false + ); +} + export function isRbacException(error: Error): boolean { return ( error instanceof Error && error.message.includes('does not have required RBAC permissions to perform action') diff --git a/src/extension.ts b/src/extension.ts index 3f769b93d..ecc44e09e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,7 @@ import { registerAzureUtilsExtensionVariables } from '@microsoft/vscode-azext-azureutils'; import { + AzExtTreeItem, callWithTelemetryAndErrorHandling, createApiProvider, createAzExtLogOutputChannel, @@ -18,56 +19,38 @@ import { TreeElementStateManager, type apiUtils, type AzExtParentTreeItem, - type AzExtTreeItem, type AzureExtensionApi, type IActionContext, - type ITreeItemPickerContext, } from '@microsoft/vscode-azext-utils'; +import { type AzureResourcesExtensionApiWithActivity } from '@microsoft/vscode-azext-utils/activity'; import { AzExtResourceType, getAzureResourcesExtensionApi } from '@microsoft/vscode-azureresources-api'; -import { platform } from 'os'; import * as vscode from 'vscode'; +import { registerAccountCommands } from './commands/account/registerAccountCommands'; import { findTreeItem } from './commands/api/findTreeItem'; import { pickTreeItem } from './commands/api/pickTreeItem'; import { revealTreeItem } from './commands/api/revealTreeItem'; -import { deleteDatabaseAccount } from './commands/deleteDatabaseAccount/deleteDatabaseAccount'; import { importDocuments } from './commands/importDocuments'; -import { - cosmosGremlinFilter, - cosmosMongoFilter, - cosmosTableFilter, - doubleClickDebounceDelay, - sqlFilter, -} from './constants'; +import { cosmosMongoFilter, doubleClickDebounceDelay, sqlFilter } from './constants'; import { DatabasesFileSystem } from './DatabasesFileSystem'; import { registerDocDBCommands } from './docdb/registerDocDBCommands'; -import { DocDBAccountTreeItem } from './docdb/tree/DocDBAccountTreeItem'; -import { type DocDBAccountTreeItemBase } from './docdb/tree/DocDBAccountTreeItemBase'; import { type DocDBCollectionTreeItem } from './docdb/tree/DocDBCollectionTreeItem'; import { DocDBDocumentTreeItem } from './docdb/tree/DocDBDocumentTreeItem'; import { ext } from './extensionVariables'; import { getResourceGroupsApi } from './getExtensionApi'; import { registerGraphCommands } from './graph/registerGraphCommands'; -import { GraphAccountTreeItem } from './graph/tree/GraphAccountTreeItem'; import { registerMongoCommands } from './mongo/registerMongoCommands'; -import { setConnectedNode } from './mongo/setConnectedNode'; -import { MongoAccountTreeItem } from './mongo/tree/MongoAccountTreeItem'; -import { type MongoCollectionTreeItem } from './mongo/tree/MongoCollectionTreeItem'; import { MongoDocumentTreeItem } from './mongo/tree/MongoDocumentTreeItem'; import { MongoClustersExtension } from './mongoClusters/MongoClustersExtension'; import { registerPostgresCommands } from './postgres/commands/registerPostgresCommands'; import { DatabaseResolver } from './resolver/AppResolver'; import { DatabaseWorkspaceProvider } from './resolver/DatabaseWorkspaceProvider'; -import { TableAccountTreeItem } from './table/tree/TableAccountTreeItem'; -import { AttachedAccountSuffix } from './tree/AttachedAccountsTreeItem'; -import { SubscriptionTreeItem } from './tree/SubscriptionTreeItem'; -import { localize } from './utils/localize'; - -const cosmosDBTopLevelContextValues: string[] = [ - GraphAccountTreeItem.contextValue, - DocDBAccountTreeItem.contextValue, - TableAccountTreeItem.contextValue, - MongoAccountTreeItem.contextValue, -]; +import { CosmosDBBranchDataProvider } from './tree/CosmosDBBranchDataProvider'; +import { CosmosDBWorkspaceBranchDataProvider } from './tree/CosmosDBWorkspaceBranchDataProvider'; +import { isTreeElementWithExperience } from './tree/TreeElementWithExperience'; +import { + SharedWorkspaceResourceProvider, + WorkspaceResourceType, +} from './tree/workspace/SharedWorkspaceResourceProvider'; export async function activateInternal( context: vscode.ExtensionContext, @@ -94,9 +77,20 @@ export async function activateInternal( // AzureResourceGraph API V1 provided by the getResourceGroupsApi call above. // TreeElementStateManager is needed here too ext.state = new TreeElementStateManager(); - ext.rgApiV2 = await getAzureResourcesExtensionApi(context, '2.0.0'); + ext.rgApiV2 = (await getAzureResourcesExtensionApi(context, '2.0.0')) as AzureResourcesExtensionApiWithActivity; + + ext.cosmosDBBranchDataProvider = new CosmosDBBranchDataProvider(); + ext.cosmosDBWorkspaceBranchDataProvider = new CosmosDBWorkspaceBranchDataProvider(); + ext.rgApiV2.resources.registerAzureResourceBranchDataProvider( + AzExtResourceType.AzureCosmosDb, + ext.cosmosDBBranchDataProvider, + ); + ext.rgApiV2.resources.registerWorkspaceResourceProvider(new SharedWorkspaceResourceProvider()); + ext.rgApiV2.resources.registerWorkspaceResourceBranchDataProvider( + WorkspaceResourceType.AttachedAccounts, + ext.cosmosDBWorkspaceBranchDataProvider, + ); - ext.rgApi.registerApplicationResourceResolver(AzExtResourceType.AzureCosmosDb, new DatabaseResolver()); ext.rgApi.registerApplicationResourceResolver( AzExtResourceType.PostgresqlServersStandard, new DatabaseResolver(), @@ -114,6 +108,7 @@ export async function activateInternal( ext.fileSystem = new DatabasesFileSystem(ext.rgApi.appResourceTree); + registerAccountCommands(); registerDocDBCommands(); registerGraphCommands(); registerPostgresCommands(); @@ -128,71 +123,38 @@ export async function activateInternal( vscode.workspace.registerFileSystemProvider(DatabasesFileSystem.scheme, ext.fileSystem), ); - registerCommandWithTreeNodeUnwrapping('cosmosDB.selectSubscriptions', () => - vscode.commands.executeCommand('azure-account.selectSubscriptions'), - ); - - registerCommandWithTreeNodeUnwrapping('azureDatabases.createServer', createServer); - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteAccount', deleteAccount); - registerCommandWithTreeNodeUnwrapping( - 'cosmosDB.attachDatabaseAccount', - async (actionContext: IActionContext) => { - await ext.attachedAccountsNode.attachNewAccount(actionContext); - await ext.rgApi.workspaceResourceTree.refresh(actionContext, ext.attachedAccountsNode); - }, - ); - registerCommandWithTreeNodeUnwrapping('cosmosDB.attachEmulator', async (actionContext: IActionContext) => { - if (platform() !== 'win32') { - actionContext.errorHandling.suppressReportIssue = true; - throw new Error( - localize('emulatorNotSupported', 'The Cosmos DB emulator is only supported on Windows.'), - ); - } - - await ext.attachedAccountsNode.attachEmulator(actionContext); - await ext.rgApi.workspaceResourceTree.refresh(actionContext, ext.attachedAccountsNode); - }); registerCommandWithTreeNodeUnwrapping( 'azureDatabases.refresh', - async (actionContext: IActionContext, node?: AzExtTreeItem) => { - if (node) { - await node.refresh(actionContext); - } else { - await ext.rgApi.appResourceTree.refresh(actionContext, node); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (actionContext: IActionContext, node?: any) => { + if (node instanceof AzExtTreeItem) { + if (node) { + await node.refresh(actionContext); + } else { + await ext.rgApi.appResourceTree.refresh(actionContext, node); + } + + return; } - }, - ); - registerCommandWithTreeNodeUnwrapping( - 'azureDatabases.detachDatabaseAccount', - async (actionContext: IActionContext & ITreeItemPickerContext, node?: AzExtTreeItem) => { - const children = await ext.attachedAccountsNode.loadAllChildren(actionContext); - if (children.length < 2) { - const message = localize('noAttachedAccounts', 'There are no Attached Accounts.'); - void vscode.window.showInformationMessage(message); - } else { - if (!node) { - node = await ext.rgApi.workspaceResourceTree.showTreeItemPicker( - cosmosDBTopLevelContextValues.map((val: string) => (val += AttachedAccountSuffix)), - actionContext, - ); - } - if (node instanceof MongoAccountTreeItem) { - if (ext.connectedMongoDB && node.fullId === ext.connectedMongoDB.parent.fullId) { - setConnectedNode(undefined); - await node.refresh(actionContext); - } - } - await ext.attachedAccountsNode.detach(node); - await ext.rgApi.workspaceResourceTree.refresh(actionContext, ext.attachedAccountsNode); + // the node is not an AzExtTreeItem, so we assume it's a TreeElementWithId, etc., based on the V2 of the Tree API from Azure-Resources + + if (isTreeElementWithExperience(node)) { + actionContext.telemetry.properties.experience = node.experience?.api; + } + + if (node && typeof node === 'object' && 'id' in node) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ext.state.notifyChildrenChanged(node.id as string); } }, ); + registerCommandWithTreeNodeUnwrapping( 'cosmosDB.importDocument', async ( actionContext: IActionContext, - selectedNode: vscode.Uri | MongoCollectionTreeItem | DocDBCollectionTreeItem, + selectedNode: vscode.Uri | DocDBCollectionTreeItem, uris: vscode.Uri[], ) => { if (selectedNode instanceof vscode.Uri) { @@ -202,21 +164,18 @@ export async function activateInternal( } }, ); - registerCommandWithTreeNodeUnwrapping('cosmosDB.copyConnectionString', cosmosDBCopyConnectionString); + registerCommandWithTreeNodeUnwrapping( 'cosmosDB.openDocument', - async (actionContext: IActionContext, node?: MongoDocumentTreeItem | DocDBDocumentTreeItem) => { + async (actionContext: IActionContext, node?: DocDBDocumentTreeItem) => { if (!node) { - node = await ext.rgApi.pickAppResource( - actionContext, - { - filter: [cosmosMongoFilter, sqlFilter], - expectedChildContextValue: [ - MongoDocumentTreeItem.contextValue, - DocDBDocumentTreeItem.contextValue, - ], - }, - ); + node = await ext.rgApi.pickAppResource(actionContext, { + filter: [cosmosMongoFilter, sqlFilter], + expectedChildContextValue: [ + MongoDocumentTreeItem.contextValue, + DocDBDocumentTreeItem.contextValue, + ], + }); } // Clear un-uploaded local changes to the document before opening https://github.com/microsoft/vscode-cosmosdb/issues/1619 @@ -265,41 +224,3 @@ export async function activateInternal( export function deactivateInternal(_context: vscode.ExtensionContext): void { // NOOP } - -export async function createServer(context: IActionContext, node?: SubscriptionTreeItem): Promise { - if (!node) { - node = await ext.rgApi.appResourceTree.showTreeItemPicker( - SubscriptionTreeItem.contextValue, - context, - ); - } - - await SubscriptionTreeItem.createChild(context, node); -} - -export async function deleteAccount(context: IActionContext, node?: AzExtTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await ext.rgApi.pickAppResource(context, { - filter: [cosmosMongoFilter, cosmosTableFilter, cosmosGremlinFilter, sqlFilter], - }); - } - - await deleteDatabaseAccount(context, node, false); -} - -export async function cosmosDBCopyConnectionString( - context: IActionContext, - node?: MongoAccountTreeItem | DocDBAccountTreeItemBase, -): Promise { - const message = 'The connection string has been copied to the clipboard'; - if (!node) { - node = await ext.rgApi.pickAppResource(context, { - filter: [cosmosMongoFilter, cosmosTableFilter, cosmosGremlinFilter, sqlFilter], - }); - } - - await vscode.env.clipboard.writeText(node.connectionString); - void vscode.window.showInformationMessage(message); -} diff --git a/src/extensionVariables.ts b/src/extensionVariables.ts index 2ad559b6f..b8620a022 100644 --- a/src/extensionVariables.ts +++ b/src/extensionVariables.ts @@ -3,59 +3,64 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - type AzExtTreeDataProvider, - type AzExtTreeItem, - type IAzExtLogOutputChannel, - type TreeElementStateManager, -} from '@microsoft/vscode-azext-utils'; +import { type IAzExtLogOutputChannel, type TreeElementStateManager } from '@microsoft/vscode-azext-utils'; +import { type AzureResourcesExtensionApiWithActivity } from '@microsoft/vscode-azext-utils/activity'; import { type AzureHostExtensionApi } from '@microsoft/vscode-azext-utils/hostapi'; -import { type AzureResourcesExtensionApi } from '@microsoft/vscode-azureresources-api'; -import { type ExtensionContext, type SecretStorage, type TreeView } from 'vscode'; +import { type ExtensionContext, type SecretStorage } from 'vscode'; import { type DatabasesFileSystem } from './DatabasesFileSystem'; import { type NoSqlCodeLensProvider } from './docdb/NoSqlCodeLensProvider'; import { type MongoDBLanguageClient } from './mongo/languageClient'; -import { type MongoCodeLensProvider } from './mongo/services/MongoCodeLensProvider'; import { type MongoDatabaseTreeItem } from './mongo/tree/MongoDatabaseTreeItem'; import { type MongoClustersBranchDataProvider } from './mongoClusters/tree/MongoClustersBranchDataProvider'; import { type MongoClustersWorkspaceBranchDataProvider } from './mongoClusters/tree/workspace/MongoClustersWorkbenchBranchDataProvider'; import { type PostgresCodeLensProvider } from './postgres/services/PostgresCodeLensProvider'; import { type PostgresDatabaseTreeItem } from './postgres/tree/PostgresDatabaseTreeItem'; import { type AttachedAccountsTreeItem } from './tree/AttachedAccountsTreeItem'; -import { type AzureAccountTreeItemWithAttached } from './tree/AzureAccountTreeItemWithAttached'; -import { type SharedWorkspaceResourceProvider } from './tree/workspace/sharedWorkspaceResourceProvider'; +import { type CosmosDBBranchDataProvider } from './tree/CosmosDBBranchDataProvider'; +import { type CosmosDBWorkspaceBranchDataProvider } from './tree/CosmosDBWorkspaceBranchDataProvider'; /** * Namespace for common variables used throughout the extension. They must be initialized in the activate() method of extension.ts */ export namespace ext { + /** + * These are used to support MongoDB scrapbook feature + * */ export let connectedMongoDB: MongoDatabaseTreeItem | undefined; + export let connectedPostgresDB: PostgresDatabaseTreeItem | undefined; + export let postgresCodeLensProvider: PostgresCodeLensProvider | undefined; + export let context: ExtensionContext; export let outputChannel: IAzExtLogOutputChannel; - export let tree: AzExtTreeDataProvider; - export let treeView: TreeView; export let attachedAccountsNode: AttachedAccountsTreeItem; export let isBundle: boolean | undefined; - export let azureAccountTreeItem: AzureAccountTreeItemWithAttached; export let secretStorage: SecretStorage; - export let postgresCodeLensProvider: PostgresCodeLensProvider | undefined; export const prefix: string = 'azureDatabases'; export let fileSystem: DatabasesFileSystem; - export let mongoCodeLensProvider: MongoCodeLensProvider; export let noSqlCodeLensProvider: NoSqlCodeLensProvider; export let mongoLanguageClient: MongoDBLanguageClient; export let rgApi: AzureHostExtensionApi; - export let rgApiV2: AzureResourcesExtensionApi; + + // Since the Azure Resources extension did not update API interface, but added a new interface with activity + // we have to use the new interface AzureResourcesExtensionApiWithActivity instead of AzureResourcesExtensionApi + export let rgApiV2: AzureResourcesExtensionApiWithActivity; export let state: TreeElementStateManager; + // TODO: To avoid these stupid variables the rgApiV2 should have the following public fields (but they are private): + // - AzureResourceProviderManager, + // - AzureResourceBranchDataProviderManager, + // - WorkspaceResourceProviderManager, + // - WorkspaceResourceBranchDataProviderManager, + + // used for the resources tree and the workspace tree REFRESH + export let cosmosDBBranchDataProvider: CosmosDBBranchDataProvider; + export let cosmosDBWorkspaceBranchDataProvider: CosmosDBWorkspaceBranchDataProvider; + // used for the resources tree export let mongoClustersBranchDataProvider: MongoClustersBranchDataProvider; - // used for the workspace: this is the general provider - export let workspaceDataProvider: SharedWorkspaceResourceProvider; - // used for the workspace: these are the dedicated providers export let mongoClustersWorkspaceBranchDataProvider: MongoClustersWorkspaceBranchDataProvider; diff --git a/src/mongo/MongoScrapbook.ts b/src/mongo/MongoScrapbookHelpers.ts similarity index 72% rename from src/mongo/MongoScrapbook.ts rename to src/mongo/MongoScrapbookHelpers.ts index 038328569..cb0b8f907 100644 --- a/src/mongo/MongoScrapbook.ts +++ b/src/mongo/MongoScrapbookHelpers.ts @@ -3,39 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - openReadOnlyContent, - parseError, - type IActionContext, - type IParsedError, - type ReadOnlyContent, -} from '@microsoft/vscode-azext-utils'; +import { parseError, type IParsedError } from '@microsoft/vscode-azext-utils'; import { ANTLRInputStream as InputStream } from 'antlr4ts/ANTLRInputStream'; import { CommonTokenStream } from 'antlr4ts/CommonTokenStream'; import { ErrorNode } from 'antlr4ts/tree/ErrorNode'; import { type ParseTree } from 'antlr4ts/tree/ParseTree'; import { TerminalNode } from 'antlr4ts/tree/TerminalNode'; import { EJSON, ObjectId } from 'bson'; -import { type Collection } from 'mongodb'; -import { EOL } from 'os'; import * as vscode from 'vscode'; -import { ext } from '../extensionVariables'; import { filterType, findType } from '../utils/array'; -import { localize } from '../utils/localize'; import { nonNullProp, nonNullValue } from '../utils/nonNull'; import { LexerErrorListener, ParserErrorListener } from './errorListeners'; import { mongoLexer } from './grammar/mongoLexer'; import * as mongoParser from './grammar/mongoParser'; import { MongoVisitor } from './grammar/visitors'; import { type ErrorDescription, type MongoCommand } from './MongoCommand'; -import { MongoCollectionTreeItem } from './tree/MongoCollectionTreeItem'; -import { stripQuotes, type MongoDatabaseTreeItem } from './tree/MongoDatabaseTreeItem'; -import { MongoDocumentTreeItem, type IMongoDocument } from './tree/MongoDocumentTreeItem'; - -const notInScrapbookMessage = 'You must have a MongoDB scrapbook (*.mongo) open to run a MongoDB command.'; +import { stripQuotes } from './tree/MongoDatabaseTreeItem'; export function getAllErrorsFromTextDocument(document: vscode.TextDocument): vscode.Diagnostic[] { - const commands = getAllCommandsFromTextDocument(document); + const commands = getAllCommandsFromText(document.getText()); const errors: vscode.Diagnostic[] = []; for (const command of commands) { for (const error of command.errors || []) { @@ -47,158 +33,6 @@ export function getAllErrorsFromTextDocument(document: vscode.TextDocument): vsc return errors; } -export async function executeAllCommandsFromActiveEditor(context: IActionContext): Promise { - ext.outputChannel.appendLog('Executing all commands in scrapbook...'); - const commands = getAllCommandsFromActiveEditor(); - await executeCommands(context, commands); -} - -export async function executeCommandFromActiveEditor( - context: IActionContext, - position?: vscode.Position, -): Promise { - const commands = getAllCommandsFromActiveEditor(); - const command = findCommandAtPosition(commands, position || vscode.window.activeTextEditor?.selection.start); - return await executeCommand(context, command); -} - -function getAllCommandsFromActiveEditor(): MongoCommand[] { - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor) { - return getAllCommandsFromTextDocument(activeEditor.document); - } else { - // Shouldn't be able to reach this - throw new Error(notInScrapbookMessage); - } -} - -export function getAllCommandsFromTextDocument(document: vscode.TextDocument): MongoCommand[] { - return getAllCommandsFromText(document.getText()); -} - -async function executeCommands(context: IActionContext, commands: MongoCommand[]): Promise { - const label: string = 'Scrapbook-execute-all-results'; - const fullId: string = `${ext.connectedMongoDB?.fullId}/${label}`; - const readOnlyContent: ReadOnlyContent = await openReadOnlyContent({ label, fullId }, '', '.txt', { - viewColumn: vscode.ViewColumn.Beside, - }); - - for (const command of commands) { - try { - await executeCommand(context, command, readOnlyContent); - } catch (e) { - const err = parseError(e); - if (err.isUserCancelledError) { - throw e; - } else { - const message = `${command.text.split('(')[0]} at ${command.range.start.line + 1}:${command.range.start.character + 1}: ${err.message}`; - throw new Error(message); - } - } - } -} - -async function executeCommand( - context: IActionContext, - command: MongoCommand, - readOnlyContent?: ReadOnlyContent, -): Promise { - if (command) { - try { - context.telemetry.properties.command = command.name; - context.telemetry.properties.argsCount = String(command.arguments ? command.arguments.length : 0); - } catch { - // Ignore - } - - const database = ext.connectedMongoDB; - if (!database) { - throw new Error( - 'Please select a MongoDB database to run against by selecting it in the explorer and selecting the "Connect" context menu item', - ); - } - if (command.errors && command.errors.length > 0) { - //Currently, we take the first error pushed. Tests correlate that the parser visits errors in left-to-right, top-to-bottom. - const err = command.errors[0]; - throw new Error( - localize( - 'unableToParseSyntax', - `Unable to parse syntax. Error near line ${err.range.start.line + 1}, column ${err.range.start.character + 1}: "${err.message}"`, - ), - ); - } - - // we don't handle chained commands so we can only handle "find" if isn't chained - if (command.name === 'find' && !command.chained) { - const db = await database.connectToDb(); - const collectionName: string = nonNullProp(command, 'collection'); - const collection: Collection = db.collection(collectionName); - // NOTE: Intentionally creating a _new_ tree item rather than searching for a cached node in the tree because - // the executed 'find' command could have a filter or projection that is not handled by a cached tree node - const node = new MongoCollectionTreeItem(database, collection, command.argumentObjects); - await ext.fileSystem.showTextDocument(node, { viewColumn: vscode.ViewColumn.Beside }); - } else { - const result = await database.executeCommand(command, context); - if (command.name === 'findOne') { - if (result === 'null') { - throw new Error(`Could not find any documents`); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - const document: IMongoDocument = EJSON.parse(result); - const collectionName: string = nonNullProp(command, 'collection'); - - const collectionId: string = `${database.fullId}/${collectionName}`; - const colNode: MongoCollectionTreeItem | undefined = await ext.rgApi.appResourceTree.findTreeItem( - collectionId, - context, - ); - if (!colNode) { - throw new Error(localize('failedToFind', 'Failed to find collection "{0}".', collectionName)); - } - const docNode = new MongoDocumentTreeItem(colNode, document); - await ext.fileSystem.showTextDocument(docNode, { viewColumn: vscode.ViewColumn.Beside }); - } else { - if (readOnlyContent) { - await readOnlyContent.append(`${result}${EOL}${EOL}`); - } else { - const label: string = 'Scrapbook-results'; - const fullId: string = `${database.fullId}/${label}`; - await openReadOnlyContent({ label, fullId }, result, '.json', { - viewColumn: vscode.ViewColumn.Beside, - }); - } - - await refreshTreeAfterCommand(database, command, context); - } - } - } else { - throw new Error('No MongoDB command found at the current cursor location.'); - } -} - -async function refreshTreeAfterCommand( - database: MongoDatabaseTreeItem, - command: MongoCommand, - context: IActionContext, -): Promise { - if (command.name === 'drop') { - await database.refresh(context); - } else if ( - command.collection && - command.name && - /^(insert|update|delete|replace|remove|write|bulkWrite)/i.test(command.name) - ) { - const collectionNode = await ext.rgApi.appResourceTree.findTreeItem( - database.fullId + '/' + command.collection, - context, - ); - if (collectionNode) { - await collectionNode.refresh(context); - } - } -} - export function getAllCommandsFromText(content: string): MongoCommand[] { const lexer = new mongoLexer(new InputStream(content)); const lexerListener = new LexerErrorListener(); diff --git a/src/mongo/MongoScrapbookService.ts b/src/mongo/MongoScrapbookService.ts new file mode 100644 index 000000000..01c4b0a68 --- /dev/null +++ b/src/mongo/MongoScrapbookService.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { openReadOnlyContent, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { EOL } from 'os'; +import * as vscode from 'vscode'; +import { ext } from '../extensionVariables'; +import { CredentialCache } from '../mongoClusters/CredentialCache'; +import { type DatabaseItemModel } from '../mongoClusters/MongoClustersClient'; +import { type MongoClusterModel } from '../mongoClusters/tree/MongoClusterModel'; +import { type MongoAccountModel } from '../tree/mongo/MongoAccountModel'; +import { type MongoCommand } from './MongoCommand'; +import { findCommandAtPosition, getAllCommandsFromText } from './MongoScrapbookHelpers'; +import { MongoShellScriptRunner } from './MongoShellScriptRunner'; +import { MongoCodeLensProvider } from './services/MongoCodeLensProvider'; + +export class MongoScrapbookServiceImpl { + //-------------------------------------------------------------------------------- + // Connection Management + //-------------------------------------------------------------------------------- + + private _cluster: MongoClusterModel | MongoAccountModel | undefined; + private _database: DatabaseItemModel | undefined; + private readonly _mongoCodeLensProvider = new MongoCodeLensProvider(); + + /** + * Provides a CodeLens provider for the workspace. + */ + public getCodeLensProvider(): MongoCodeLensProvider { + return this._mongoCodeLensProvider; + } + + /** + * Sets the current cluster and database, updating the CodeLens provider. + */ + public async setConnectedCluster(cluster: MongoClusterModel | MongoAccountModel, database: DatabaseItemModel) { + // Update information + this._cluster = cluster; + this._database = database; + this._mongoCodeLensProvider.updateCodeLens(); + + // Update the Language Client/Server + // The language server needs credentials to connect to the cluster.. + await ext.mongoLanguageClient.connect( + CredentialCache.getConnectionStringWithPassword(this._cluster.id), + this._database.name, + ); + } + + /** + * Clears the current connection. + */ + public async clearConnection() { + this._cluster = undefined; + this._database = undefined; + this._mongoCodeLensProvider.updateCodeLens(); + await ext.mongoLanguageClient.disconnect(); + } + + /** + * Returns true if a cluster and database are set. + */ + public isConnected(): boolean { + return !!this._cluster && !!this._database; + } + + /** + * Returns the current database name. + */ + public getDatabaseName(): string | undefined { + return this._database?.name; + } + + /** + * Returns the current cluster ID. + */ + public getClusterId(): string | undefined { + return this._cluster?.id; + } + + /** + * Returns a friendly display name of the connected cluster/database. + */ + public getDisplayName(): string | undefined { + return this._cluster && this._database ? `${this._cluster.name}/${this._database.name}` : undefined; + } + + //-------------------------------------------------------------------------------- + // Command Execution + //-------------------------------------------------------------------------------- + + private _isExecutingAllCommands: boolean = false; + private _singleCommandInExecution: MongoCommand | undefined; + + /** + * Executes all Mongo commands in the given document. + * + * Note: This method will call use() before executing the commands to + * ensure that the commands are run in the correct database. It's done for backwards + * compatibility with the previous behavior. + */ + public async executeAllCommands(context: IActionContext, document: vscode.TextDocument): Promise { + if (!this.isConnected()) { + throw new Error('Please connect to a MongoDB database before running a Scrapbook command.'); + } + + const commands: MongoCommand[] = getAllCommandsFromText(document.getText()); + if (!commands.length) { + void vscode.window.showInformationMessage('No commands found in this document.'); + return; + } + + this.setExecutingAllCommandsFlag(true); + try { + const label = 'Scrapbook-run-all-results'; + const fullId = `${this.getDisplayName()}/${label}`; + + const readOnlyContent = await openReadOnlyContent({ label, fullId }, '', '.json', { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, + }); + + const shellRunner = await MongoShellScriptRunner.createShell(context, { + connectionString: CredentialCache.getConnectionStringWithPassword(this.getClusterId()!), + isEmulator: false, + }); + + try { + // preselect the database for the user + // this is done for backwards compatibility with the previous behavior + await shellRunner.executeScript(`use(\`${MongoScrapbookService.getDatabaseName()}\`)`); + + for (const cmd of commands) { + await this.executeSingleCommand(context, cmd, readOnlyContent, shellRunner); + } + } finally { + shellRunner.dispose(); + } + } finally { + this.setExecutingAllCommandsFlag(false); + } + } + + /** + * Executes a single Mongo command defined at the specified position in the document. + * + * Note: This method will call use() before executing the command to + * ensure that the command are is in the correct database. It's done for backwards + * compatibility with the previous behavior. + */ + public async executeCommandAtPosition( + context: IActionContext, + document: vscode.TextDocument, + position: vscode.Position, + ): Promise { + if (!this.isConnected()) { + throw new Error('Please connect to a MongoDB database before running a Scrapbook command.'); + } + + const commands = getAllCommandsFromText(document.getText()); + const command = findCommandAtPosition(commands, position); + + const label = 'Scrapbook-run-command-results'; + const fullId = `${this.getDisplayName()}/${label}`; + const readOnlyContent = await openReadOnlyContent({ label, fullId }, '', '.json', { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, + }); + + await this.executeSingleCommand(context, command, readOnlyContent, undefined, this.getDatabaseName()); + } + + /** + * Indicates whether multiple commands are being executed at once. + */ + public isExecutingAllCommands(): boolean { + return this._isExecutingAllCommands; + } + + /** + * Records the state for whether all commands are executing. + */ + public setExecutingAllCommandsFlag(state: boolean): void { + this._isExecutingAllCommands = state; + this._mongoCodeLensProvider.updateCodeLens(); + } + + /** + * Returns the command currently in execution, if any. + */ + public getSingleCommandInExecution(): MongoCommand | undefined { + return this._singleCommandInExecution; + } + + /** + * Sets or clears the command currently being executed. + */ + public setSingleCommandInExecution(command: MongoCommand | undefined): void { + this._singleCommandInExecution = command; + this._mongoCodeLensProvider.updateCodeLens(); + } + + //-------------------------------------------------------------------------------- + // Internal Helpers + //-------------------------------------------------------------------------------- + + /** + * Runs a single command against the Mongo shell. If a shell instance is not provided, + * this method creates its own, executes the command, then disposes the shell. This + * includes error handling for parse problems, ephemeral shell usage, and optional + * output to a read-only content view. + */ + private async executeSingleCommand( + context: IActionContext, + command: MongoCommand, + readOnlyContent?: { append(value: string): Promise }, + shellRunner?: MongoShellScriptRunner, + preselectedDatabase?: string, // this will run the 'use ' command before the actual command. + ): Promise { + if (!this.isConnected()) { + throw new Error('Not connected to any MongoDB database.'); + } + + if (command.errors?.length) { + const firstErr = command.errors[0]; + throw new Error( + `Unable to parse syntax near line ${firstErr.range.start.line + 1}, col ${firstErr.range.start.character + 1}: ${firstErr.message}`, + ); + } + + this.setSingleCommandInExecution(command); + let ephemeralShell = false; + + try { + if (!shellRunner) { + shellRunner = await MongoShellScriptRunner.createShell(context, { + connectionString: CredentialCache.getConnectionStringWithPassword(this.getClusterId()!), + isEmulator: false, + }); + ephemeralShell = true; + } + + if (preselectedDatabase) { + await shellRunner.executeScript(`use(\`${preselectedDatabase}\`)`); + } + + const result = await shellRunner.executeScript(command.text); + if (!result) { + throw new Error('No result returned from the MongoDB shell.'); + } + + if (readOnlyContent) { + await readOnlyContent.append(result + EOL + EOL); + } else { + const fallbackLabel = 'Scrapbook-results'; + const fallbackId = `${this.getDatabaseName()}/${fallbackLabel}`; + await openReadOnlyContent({ label: fallbackLabel, fullId: fallbackId }, result, '.json', { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, + }); + } + } finally { + this.setSingleCommandInExecution(undefined); + + if (ephemeralShell) { + shellRunner?.dispose(); + } + } + } +} + +// Export a single instance that the rest of your extension can import +export const MongoScrapbookService = new MongoScrapbookServiceImpl(); diff --git a/src/mongo/MongoShell.ts b/src/mongo/MongoShell.ts deleted file mode 100644 index 54bddba84..000000000 --- a/src/mongo/MongoShell.ts +++ /dev/null @@ -1,204 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { parseError } from '@microsoft/vscode-azext-utils'; -import * as os from 'os'; -import * as vscode from 'vscode'; -import { InteractiveChildProcess } from '../utils/InteractiveChildProcess'; -import { randomUtils } from '../utils/randomUtils'; -import { getBatchSizeSetting } from '../utils/workspacUtils'; -import { wrapError } from '../utils/wrapError'; - -const timeoutMessage = - "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting."; - -const mongoShellMoreMessage = 'Type "it" for more'; -const extensionMoreMessage = '(More)'; - -const sentinelBase = 'EXECUTION COMPLETED'; -const sentinelRegex = /"?EXECUTION COMPLETED [0-9a-fA-F]{10}"?/; -function createSentinel(): string { - return `${sentinelBase} ${randomUtils.getRandomHexString(10)}`; -} - -export class MongoShell extends vscode.Disposable { - constructor( - private _process: InteractiveChildProcess, - private _timeoutSeconds: number, - ) { - super(() => this.dispose()); - } - - public static async create( - execPath: string, - execArgs: string[], - connectionString: string, - isEmulator: boolean | undefined, - outputChannel: vscode.OutputChannel, - timeoutSeconds: number, - ): Promise { - try { - const args: string[] = execArgs.slice() || []; // Snapshot since we modify it - args.push(connectionString); - - if (isEmulator) { - // Without these the connection will fail due to the self-signed DocDB certificate - if (args.indexOf('--ssl') < 0) { - args.push('--ssl'); - } - if (args.indexOf('--sslAllowInvalidCertificates') < 0) { - args.push('--sslAllowInvalidCertificates'); - } - } - - const process: InteractiveChildProcess = await InteractiveChildProcess.create({ - outputChannel: outputChannel, - command: execPath, - args, - outputFilterSearch: sentinelRegex, - outputFilterReplace: '', - }); - const shell: MongoShell = new MongoShell(process, timeoutSeconds); - - // Try writing an empty script to verify the process is running correctly and allow us - // to catch any errors related to the start-up of the process before trying to write to it. - await shell.executeScript(''); - - // Configure the batch size - await shell.executeScript(`DBQuery.shellBatchSize = ${getBatchSizeSetting()}`); - - return shell; - } catch (error) { - throw wrapCheckOutputWindow(error); - } - } - - public dispose(): void { - this._process.kill(); - } - - public async useDatabase(database: string): Promise { - return await this.executeScript(`use ${database}`); - } - - public async executeScript(script: string): Promise { - script = convertToSingleLine(script); - - let stdOut = ''; - const sentinel = createSentinel(); - - const disposables: vscode.Disposable[] = []; - try { - // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor - const result = await new Promise(async (resolve, reject) => { - try { - startScriptTimeout(this._timeoutSeconds, reject); - - // Hook up events - disposables.push( - this._process.onStdOut((text) => { - stdOut += text; - // eslint-disable-next-line prefer-const - let { text: stdOutNoSentinel, removed } = removeSentinel(stdOut, sentinel); - if (removed) { - // The sentinel was found, which means we are done. - - // Change the "type 'it' for more" message to one that doesn't ask users to type anything, - // since we're not currently interactive like that. - // CONSIDER: Ideally we would allow users to click a button to iterate through more data, - // or even just do it for them - stdOutNoSentinel = stdOutNoSentinel.replace( - mongoShellMoreMessage, - extensionMoreMessage, - ); - - resolve(stdOutNoSentinel); - } - }), - ); - disposables.push( - this._process.onStdErr((text) => { - // Mongo shell only writes to STDERR for errors relating to starting up. Script errors go to STDOUT. - // So consider this an error. - // (It's okay if we fire this multiple times, the first one wins.) - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(wrapCheckOutputWindow(text.trim())); - }), - ); - disposables.push( - this._process.onError((error) => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(error); - }), - ); - - // Write the script to STDIN - if (script) { - this._process.writeLine(script); - } - - // Mark end of result by sending the sentinel wrapped in quotes so the console will spit - // it back out as a string value after it's done processing the script - const quotedSentinel = `"${sentinel}"`; - this._process.writeLine(quotedSentinel); // (Don't display the sentinel) - } catch (error) { - // new Promise() doesn't seem to catch exceptions in an async function, we need to explicitly reject it - - if ((<{ code?: string }>error).code === 'EPIPE') { - // Give a chance for start-up errors to show up before rejecting with this more general error message - await delay(500); - // eslint-disable-next-line no-ex-assign - error = new Error('The process exited prematurely.'); - } - - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(wrapCheckOutputWindow(error)); - } - }); - - return result.trim(); - } finally { - // Dispose event handlers - for (const d of disposables) { - d.dispose(); - } - } - } -} - -function startScriptTimeout(timeoutSeconds: number, reject: (err: unknown) => void): void { - if (timeoutSeconds > 0) { - setTimeout(() => { - reject(timeoutMessage); - }, timeoutSeconds * 1000); - } -} - -function convertToSingleLine(script: string): string { - return script - .split(os.EOL) - .map((line) => line.trim()) - .join(''); -} - -function removeSentinel(text: string, sentinel: string): { text: string; removed: boolean } { - const index = text.indexOf(sentinel); - if (index >= 0) { - return { text: text.slice(0, index), removed: true }; - } else { - return { text, removed: false }; - } -} - -async function delay(milliseconds: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} - -function wrapCheckOutputWindow(error: unknown): unknown { - const checkOutputMsg = 'The output window may contain additional information.'; - return parseError(error).message.includes(checkOutputMsg) ? error : wrapError(error, checkOutputMsg); -} diff --git a/src/mongo/MongoShellScriptRunner.ts b/src/mongo/MongoShellScriptRunner.ts new file mode 100644 index 000000000..42f9f2199 --- /dev/null +++ b/src/mongo/MongoShellScriptRunner.ts @@ -0,0 +1,427 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { nonNullValue, parseError, UserCancelledError, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { ext } from '../extensionVariables'; +import * as cpUtils from '../utils/cp'; +import { InteractiveChildProcess } from '../utils/InteractiveChildProcess'; +import { randomUtils } from '../utils/randomUtils'; +import { getBatchSizeSetting } from '../utils/workspacUtils'; +import { wrapError } from '../utils/wrapError'; + +const mongoExecutableFileName = process.platform === 'win32' ? 'mongo.exe' : 'mongosh'; + +const timeoutMessage = + "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting."; + +const mongoShellMoreMessage = 'Type "it" for more'; +const extensionMoreMessage = '(More)'; + +const sentinelBase = 'EXECUTION COMPLETED'; +const sentinelRegex = /"?EXECUTION COMPLETED [0-9a-fA-F]{10}"?/; +function createSentinel(): string { + return `${sentinelBase} ${randomUtils.getRandomHexString(10)}`; +} + +export class MongoShellScriptRunner extends vscode.Disposable { + private static _previousShellPathSetting: string | undefined; + private static _cachedShellPathOrCmd: string | undefined; + + private constructor( + private _process: InteractiveChildProcess, + private _timeoutSeconds: number, + ) { + super(() => this.dispose()); + } + + public static async createShellProcessHelper( + execPath: string, + execArgs: string[], + connectionString: string, + isEmulator: boolean | undefined, + outputChannel: vscode.OutputChannel, + timeoutSeconds: number, + ): Promise { + try { + const args: string[] = execArgs.slice() || []; // Snapshot since we modify it + args.push(connectionString); + + if (isEmulator) { + // Without these the connection will fail due to the self-signed DocDB certificate + if (args.indexOf('--ssl') < 0) { + args.push('--ssl'); + } + if (args.indexOf('--sslAllowInvalidCertificates') < 0) { + args.push('--sslAllowInvalidCertificates'); + } + } + + const process: InteractiveChildProcess = await InteractiveChildProcess.create({ + outputChannel: outputChannel, + command: execPath, + args, + outputFilterSearch: sentinelRegex, + outputFilterReplace: '', + }); + const shell: MongoShellScriptRunner = new MongoShellScriptRunner(process, timeoutSeconds); + + // Try writing an empty script to verify the process is running correctly and allow us + // to catch any errors related to the start-up of the process before trying to write to it. + await shell.executeScript(''); + + // Configure the batch size + await shell.executeScript(`config.set("displayBatchSize", ${getBatchSizeSetting()})`); + + return shell; + } catch (error) { + throw wrapCheckOutputWindow(error); + } + } + + public static async createShell( + context: IActionContext, + connectionInfo: { connectionString: string; isEmulator: boolean }, + ): Promise { + const config = vscode.workspace.getConfiguration(); + let shellPath: string | undefined = config.get(ext.settingsKeys.mongoShellPath); + const shellArgs: string[] = config.get(ext.settingsKeys.mongoShellArgs, []); + + if ( + !shellPath || + !MongoShellScriptRunner._cachedShellPathOrCmd || + MongoShellScriptRunner._previousShellPathSetting !== shellPath + ) { + // Only do this if setting changed since last time + shellPath = await MongoShellScriptRunner._determineShellPathOrCmd(context, shellPath); + MongoShellScriptRunner._previousShellPathSetting = shellPath; + } + MongoShellScriptRunner._cachedShellPathOrCmd = shellPath; + + const timeout = + 1000 * nonNullValue(config.get(ext.settingsKeys.mongoShellTimeout), 'mongoShellTimeout'); + return MongoShellScriptRunner.createShellProcessHelper( + shellPath, + shellArgs, + connectionInfo.connectionString, + connectionInfo.isEmulator, + ext.outputChannel, + timeout, + ); + } + + public dispose(): void { + this._process.kill(); + } + + public async useDatabase(database: string): Promise { + return await this.executeScript(`use ${database}`); + } + + public async executeScript(script: string): Promise { + // 1. Convert to single line (existing logic) + script = convertToSingleLine(script); + + // 2. If the user typed something, wrap it in EJSON.stringify(...) + // This assumes the user has typed exactly one expression that + // returns something (e.g. db.hostInfo(), db.myCollection.find(), etc.) + if (script.trim().length > 0 && !script.startsWith('print(EJSON.stringify(')) { + // Remove trailing semicolons plus any trailing space + // e.g. "db.hostInfo(); " => "db.hostInfo()" + script = script.replace(/;+\s*$/, ''); + + // Wrap in EJSON.stringify(...) + script = `print(EJSON.stringify(${script}, null, 4))`; + } + + let stdOut = ''; + const sentinel = createSentinel(); + + const disposables: vscode.Disposable[] = []; + try { + // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor + const result = await new Promise(async (resolve, reject) => { + try { + startScriptTimeout(this._timeoutSeconds, reject); + + // Hook up events + disposables.push( + this._process.onStdOut((text) => { + stdOut += text; + // eslint-disable-next-line prefer-const + let { text: stdOutNoSentinel, removed } = removeSentinel(stdOut, sentinel); + if (removed) { + // The sentinel was found, which means we are done. + + // Change the "type 'it' for more" message to one that doesn't ask users to type anything, + // since we're not currently interactive like that. + // CONSIDER: Ideally we would allow users to click a button to iterate through more data, + // or even just do it for them + stdOutNoSentinel = stdOutNoSentinel.replace( + mongoShellMoreMessage, + extensionMoreMessage, + ); + + const responseText = removePromptLeadingAndTrailing(stdOutNoSentinel); + + resolve(responseText); + } + }), + ); + disposables.push( + this._process.onStdErr((text) => { + // Mongo shell only writes to STDERR for errors relating to starting up. Script errors go to STDOUT. + // So consider this an error. + // (It's okay if we fire this multiple times, the first one wins.) + + // Split the stderr text into lines, trim them, and remove empty lines + const lines: string[] = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + + // Filter out lines recognized as benign debug/telemetry info + const unknownErrorLines: string[] = lines.filter( + (line) => !this.isNonErrorMongoshStderrLine(line), + ); + + // If there are any lines left after filtering, assume they are real errors + if (unknownErrorLines.length > 0) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(wrapCheckOutputWindow(unknownErrorLines.join('\n'))); + } else { + // Otherwise, ignore the lines since they're known safe + // (e.g. "Debugger listening on ws://..." or "Using Mongosh: 1.9.0", etc.) + } + }), + ); + disposables.push( + this._process.onError((error) => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(error); + }), + ); + + // Write the script to STDIN + if (script) { + this._process.writeLine(script); + } + + // Mark end of result by sending the sentinel wrapped in quotes so the console will spit + // it back out as a string value after it's done processing the script + const quotedSentinel = `"${sentinel}"`; + this._process.writeLine(quotedSentinel); // (Don't display the sentinel) + } catch (error) { + // new Promise() doesn't seem to catch exceptions in an async function, we need to explicitly reject it + + if ((<{ code?: string }>error).code === 'EPIPE') { + // Give a chance for start-up errors to show up before rejecting with this more general error message + await delay(500); + // eslint-disable-next-line no-ex-assign + error = new Error('The process exited prematurely.'); + } + + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(wrapCheckOutputWindow(error)); + } + }); + + return result.trim(); + } finally { + // Dispose event handlers + for (const d of disposables) { + d.dispose(); + } + } + } + + /** + * Checks if the stderr line from mongosh is a known "benign" message that + * should NOT be treated as an error. + */ + private isNonErrorMongoshStderrLine(line: string): boolean { + /** + * Certain versions of mongosh can print debug or telemetry messages to stderr + * that are not actually errors (especially if VS Code auto-attach is running). + * Below is a list of known message fragments that we can safely ignore. + * + * IMPORTANT: This list is not exhaustive and may need to be updated as new + * versions of mongosh introduce new messages. + */ + const knownNonErrorSubstrings: string[] = [ + // Node.js Inspector (auto-attach) messages: + 'Debugger listening on ws://', + 'Debugger attached.', + 'For help, see: https://nodejs.org/en/docs/inspector', + + // MongoDB Shell general info messages: + 'Current Mongosh Log ID:', + 'Using Mongosh:', + 'Using MongoDB:', + + // Telemetry or analytics prompts: + 'To enable telemetry, run:', + 'Disable telemetry by running:', + + // Occasionally, devtools or local shell info: + 'DevTools listening on ws://', + 'The server generated these startup warnings:', + ]; + + return knownNonErrorSubstrings.some((pattern) => line.includes(pattern)); + } + + private static async _determineShellPathOrCmd( + context: IActionContext, + shellPathSetting: string | undefined, + ): Promise { + if (!shellPathSetting) { + // User hasn't specified the path + if (await cpUtils.commandSucceeds('mongo', '--version')) { + // If the user already has mongo in their system path, just use that + return 'mongo'; + } else { + // If all else fails, prompt the user for the mongo path + const openFile: vscode.MessageItem = { title: `Browse to ${mongoExecutableFileName}` }; + const browse: vscode.MessageItem = { title: 'Open installation page' }; + const noMongoError: string = + 'This functionality requires the Mongo DB shell, but we could not find it in the path or using the mongo.shell.path setting.'; + const response = await context.ui.showWarningMessage( + noMongoError, + { stepName: 'promptForMongoPath' }, + browse, + openFile, + ); + if (response === openFile) { + // eslint-disable-next-line no-constant-condition + while (true) { + const newPath: vscode.Uri[] = await context.ui.showOpenDialog({ + filters: { 'Executable Files': [process.platform === 'win32' ? 'exe' : ''] }, + openLabel: `Select ${mongoExecutableFileName}`, + stepName: 'openMongoExeFile', + }); + const fsPath = newPath[0].fsPath; + const baseName = path.basename(fsPath); + if (baseName !== mongoExecutableFileName) { + const useAnyway: vscode.MessageItem = { title: 'Use anyway' }; + const tryAgain: vscode.MessageItem = { title: 'Try again' }; + const response2 = await context.ui.showWarningMessage( + `Expected a file named "${mongoExecutableFileName}, but the selected filename is "${baseName}"`, + { stepName: 'confirmMongoExeFile' }, + useAnyway, + tryAgain, + ); + if (response2 === tryAgain) { + continue; + } + } + + await vscode.workspace + .getConfiguration() + .update(ext.settingsKeys.mongoShellPath, fsPath, vscode.ConfigurationTarget.Global); + return fsPath; + } + } else if (response === browse) { + void vscode.commands.executeCommand( + 'vscode.open', + vscode.Uri.parse('https://docs.mongodb.com/manual/installation/'), + ); + // default down to cancel error because MongoShell.create errors out if undefined is passed as the shellPath + } + + throw new UserCancelledError('createShell'); + } + } else { + // User has specified the path or command. Sometimes they set the folder instead of a path to the file, let's check that and auto fix + if (await fse.pathExists(shellPathSetting)) { + const stat = await fse.stat(shellPathSetting); + if (stat.isDirectory()) { + return path.join(shellPathSetting, mongoExecutableFileName); + } + } + + return shellPathSetting; + } + } +} + +function startScriptTimeout(timeoutSeconds: number, reject: (err: unknown) => void): void { + if (timeoutSeconds > 0) { + setTimeout(() => { + reject(timeoutMessage); + }, timeoutSeconds * 1000); + } +} + +function convertToSingleLine(script: string): string { + return script + .split(os.EOL) + .map((line) => line.trim()) + .join(''); +} + +function removeSentinel(text: string, sentinel: string): { text: string; removed: boolean } { + const index = text.indexOf(sentinel); + if (index >= 0) { + return { text: text.slice(0, index), removed: true }; + } else { + return { text, removed: false }; + } +} + +/** + * Removes a Mongo shell prompt line if it exists at the very start or the very end of `text`. + */ +function removePromptLeadingAndTrailing(text: string): string { + // Trim trailing spaces/newlines, but keep internal newlines. + text = text.replace(/\s+$/, ''); + + // Regex to detect standard MongoDB shell prompts: + // 1) [mongos] secondDb> + // 2) [mongo] test> + // 3) globaldb [primary] SampleDB> + const promptRegex = /^(\[mongo.*?\].*?>|.*?\[.*?\]\s+\S+>)$/; + + // Check if the *first line* contains a prompt + const firstNewlineIndex = text.indexOf('\n'); + if (firstNewlineIndex === -1) { + return text.replace(promptRegex, '').trim(); + } + + // Extract the first line + const firstLine = text.substring(0, firstNewlineIndex).trim(); + if (promptRegex.test(firstLine)) { + // Remove the prompt from the first line + text = text.replace(firstLine, firstLine.replace(promptRegex, '').trim()); + } + + // Check if the *last line* contains a prompt + const lastNewlineIndex = text.lastIndexOf('\n'); + if (lastNewlineIndex === -1) { + return text.replace(promptRegex, '').trim(); + } + + const lastLine = text.substring(lastNewlineIndex + 1).trim(); + if (promptRegex.test(lastLine)) { + // Remove the prompt from the last line + text = text.replace(lastLine, lastLine.replace(promptRegex, '').trim()); + } + + return text; +} + +async function delay(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} + +function wrapCheckOutputWindow(error: unknown): unknown { + const checkOutputMsg = 'The output window may contain additional information.'; + return parseError(error).message.includes(checkOutputMsg) ? error : wrapError(error, checkOutputMsg); +} diff --git a/src/mongo/commands/connectMongoDatabase.ts b/src/mongo/commands/connectMongoDatabase.ts index 20de5c4f9..db8547d9d 100644 --- a/src/mongo/commands/connectMongoDatabase.ts +++ b/src/mongo/commands/connectMongoDatabase.ts @@ -3,64 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - callWithTelemetryAndErrorHandling, - type AzExtTreeItem, - type IActionContext, - type ITreeItemPickerContext, -} from '@microsoft/vscode-azext-utils'; -import { MongoExperience, type Experience } from '../../AzureDBExperiences'; -import { ext } from '../../extensionVariables'; -import { setConnectedNode } from '../setConnectedNode'; -import { MongoDatabaseTreeItem } from '../tree/MongoDatabaseTreeItem'; -import { pickMongo } from './pickMongo'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { type CollectionItem } from '../../mongoClusters/tree/CollectionItem'; +import { type DatabaseItem } from '../../mongoClusters/tree/DatabaseItem'; +import { localize } from '../../utils/localize'; +import { MongoScrapbookService } from '../MongoScrapbookService'; export const connectedMongoKey: string = 'ms-azuretools.vscode-cosmosdb.connectedDB'; -export async function loadPersistedMongoDB(): Promise { - return callWithTelemetryAndErrorHandling('cosmosDB.loadPersistedMongoDB', async (context: IActionContext) => { - context.errorHandling.suppressDisplay = true; - context.telemetry.properties.isActivationEvent = 'true'; - - try { - const persistedNodeId: string | undefined = ext.context.globalState.get(connectedMongoKey); - if (persistedNodeId && (!ext.connectedMongoDB || ext.connectedMongoDB.fullId !== persistedNodeId)) { - const persistedNode = await ext.rgApi.appResourceTree.findTreeItem(persistedNodeId, context); - if (persistedNode) { - await ext.mongoLanguageClient.client.onReady(); - await connectMongoDatabase(context, persistedNode as MongoDatabaseTreeItem); - } - } - } finally { - // Get code lens provider out of initializing state if there's no connected DB - if (!ext.connectedMongoDB) { - ext.mongoCodeLensProvider.setConnectedDatabase(undefined); - } - } - }); -} - -export async function connectMongoDatabase(context: IActionContext, node?: MongoDatabaseTreeItem): Promise { +export async function connectMongoDatabase( + _context: IActionContext, + node?: DatabaseItem | CollectionItem, +): Promise { if (!node) { - // Include defaultExperience in the context to prevent https://github.com/microsoft/vscode-cosmosdb/issues/1517 - const experienceContext: ITreeItemPickerContext & { defaultExperience?: Experience } = { - ...context, - defaultExperience: MongoExperience, - }; - node = await pickMongo(experienceContext, MongoDatabaseTreeItem.contextValue); + await vscode.window.showInformationMessage( + localize( + 'mongo.scrapbook.howtoconnect', + 'You can connect to a different Mongo Cluster by:\n\n' + + "1. Locating the one you'd like from the resource view,\n" + + '2. Selecting a database or a collection,\n' + + '3. Right-clicking and then choosing the "Mongo Scrapbook" submenu,\n' + + '4. Selecting the "Connect to this database" command.', + ), + { modal: true }, + ); + return; } - const oldNodeId: string | undefined = ext.connectedMongoDB && ext.connectedMongoDB.fullId; - await ext.mongoLanguageClient.connect(node.connectionString, node.databaseName); - void ext.context.globalState.update(connectedMongoKey, node.fullId); - setConnectedNode(node); - await node.refresh(context); - - if (oldNodeId) { - // We have to use findTreeItem to get the instance of the old node that's being displayed in the ext.rgApi.appResourceTree. Our specific instance might have been out-of-date - const oldNode: AzExtTreeItem | undefined = await ext.rgApi.appResourceTree.findTreeItem(oldNodeId, context); - if (oldNode) { - await oldNode.refresh(context); - } - } + MongoScrapbookService.setConnectedCluster(node.mongoCluster, node.databaseInfo); } diff --git a/src/mongo/commands/createMongoCollection.ts b/src/mongo/commands/createMongoCollection.ts deleted file mode 100644 index b5493b457..000000000 --- a/src/mongo/commands/createMongoCollection.ts +++ /dev/null @@ -1,17 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { MongoDatabaseTreeItem } from '../tree/MongoDatabaseTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function createMongoCollection(context: IActionContext, node?: MongoDatabaseTreeItem): Promise { - if (!node) { - node = await pickMongo(context, MongoDatabaseTreeItem.contextValue); - } - const collectionNode = await node.createChild(context); - await vscode.commands.executeCommand('cosmosDB.connectMongoDB', collectionNode.parent); -} diff --git a/src/mongo/commands/createMongoDatabase.ts b/src/mongo/commands/createMongoDatabase.ts deleted file mode 100644 index a527ecaea..000000000 --- a/src/mongo/commands/createMongoDatabase.ts +++ /dev/null @@ -1,20 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { type MongoAccountTreeItem } from '../tree/MongoAccountTreeItem'; -import { type MongoDatabaseTreeItem } from '../tree/MongoDatabaseTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function createMongoDatabase(context: IActionContext, node?: MongoAccountTreeItem): Promise { - if (!node) { - node = await pickMongo(context); - } - const databaseNode = await node.createChild(context); - await databaseNode.createChild(context); - - await vscode.commands.executeCommand('cosmosDB.connectMongoDB', databaseNode); -} diff --git a/src/mongo/commands/createMongoDocument.ts b/src/mongo/commands/createMongoDocument.ts deleted file mode 100644 index b00994c5a..000000000 --- a/src/mongo/commands/createMongoDocument.ts +++ /dev/null @@ -1,17 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { MongoCollectionTreeItem } from '../tree/MongoCollectionTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function createMongoDocument(context: IActionContext, node?: MongoCollectionTreeItem): Promise { - if (!node) { - node = await pickMongo(context, MongoCollectionTreeItem.contextValue); - } - const documentNode = await node.createChild(context); - await vscode.commands.executeCommand('cosmosDB.openDocument', documentNode); -} diff --git a/src/mongo/commands/createMongoScrapbook.ts b/src/mongo/commands/createMongoScrapbook.ts index 253594ffe..5d0b9ad46 100644 --- a/src/mongo/commands/createMongoScrapbook.ts +++ b/src/mongo/commands/createMongoScrapbook.ts @@ -3,8 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type CollectionItem } from '../../mongoClusters/tree/CollectionItem'; +import { type DatabaseItem } from '../../mongoClusters/tree/DatabaseItem'; import * as vscodeUtil from '../../utils/vscodeUtils'; +import { MongoScrapbookService } from '../MongoScrapbookService'; -export async function createMongoSrapbook(): Promise { - await vscodeUtil.showNewFile('', 'Scrapbook', '.mongo'); +export async function createMongoSrapbook( + _context: IActionContext, + node: DatabaseItem | CollectionItem, +): Promise { + const initialFileContents: string = '// MongoDB API Scrapbook: Use this file to run MongoDB API commands\n\n'; + + // if (node instanceof CollectionItem) { + // initialFileContents += `\n\n// You are connected to the "${node.collectionInfo.name}" collection in the "${node.databaseInfo.name}" database.`; + // } else if (node instanceof DatabaseItem) { + // initialFileContents += `\n\n// You are connected to the "${node.databaseInfo.name}" database.`; + // } + + MongoScrapbookService.setConnectedCluster(node.mongoCluster, node.databaseInfo); + + await vscodeUtil.showNewFile(initialFileContents, 'Scrapbook', '.mongo'); } diff --git a/src/mongo/commands/deleteMongoCollection.ts b/src/mongo/commands/deleteMongoCollection.ts deleted file mode 100644 index 1cbe0a3e0..000000000 --- a/src/mongo/commands/deleteMongoCollection.ts +++ /dev/null @@ -1,17 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import { MongoCollectionTreeItem } from '../tree/MongoCollectionTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function deleteMongoCollection(context: IActionContext, node?: MongoCollectionTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickMongo(context, MongoCollectionTreeItem.contextValue); - } - await node.deleteTreeItem(context); -} diff --git a/src/mongo/commands/deleteMongoDatabase.ts b/src/mongo/commands/deleteMongoDatabase.ts deleted file mode 100644 index c9069e07b..000000000 --- a/src/mongo/commands/deleteMongoDatabase.ts +++ /dev/null @@ -1,31 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { ext } from '../../extensionVariables'; -import { localize } from '../../utils/localize'; -import { setConnectedNode } from '../setConnectedNode'; -import { MongoDatabaseTreeItem } from '../tree/MongoDatabaseTreeItem'; -import { connectedMongoKey } from './connectMongoDatabase'; -import { pickMongo } from './pickMongo'; - -export async function deleteMongoDB(context: IActionContext, node?: MongoDatabaseTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickMongo(context, MongoDatabaseTreeItem.contextValue); - } - await node.deleteTreeItem(context); - if (ext.connectedMongoDB && ext.connectedMongoDB.fullId === node.fullId) { - setConnectedNode(undefined); - void ext.context.globalState.update(connectedMongoKey, undefined); - // Temporary workaround for https://github.com/microsoft/vscode-cosmosdb/issues/1754 - void ext.mongoLanguageClient.disconnect(); - } - const successMessage = localize('deleteMongoDatabaseMsg', 'Successfully deleted database "{0}"', node.databaseName); - void vscode.window.showInformationMessage(successMessage); - ext.outputChannel.info(successMessage); -} diff --git a/src/mongo/commands/deleteMongoDocument.ts b/src/mongo/commands/deleteMongoDocument.ts deleted file mode 100644 index 86e0122b9..000000000 --- a/src/mongo/commands/deleteMongoDocument.ts +++ /dev/null @@ -1,17 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import { MongoDocumentTreeItem } from '../tree/MongoDocumentTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function deleteMongoDocument(context: IActionContext, node?: MongoDocumentTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickMongo(context, MongoDocumentTreeItem.contextValue); - } - await node.deleteTreeItem(context); -} diff --git a/src/mongo/commands/executeAllMongoCommand.ts b/src/mongo/commands/executeAllMongoCommand.ts index 8ff3fadab..be23a9eb1 100644 --- a/src/mongo/commands/executeAllMongoCommand.ts +++ b/src/mongo/commands/executeAllMongoCommand.ts @@ -4,10 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { executeAllCommandsFromActiveEditor } from '../MongoScrapbook'; -import { loadPersistedMongoDB } from './connectMongoDatabase'; +import * as vscode from 'vscode'; +import { withProgress } from '../../utils/withProgress'; +import { MongoScrapbookService } from '../MongoScrapbookService'; export async function executeAllMongoCommand(context: IActionContext): Promise { - await loadPersistedMongoDB(); - await executeAllCommandsFromActiveEditor(context); + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error('You must open a *.mongo file to run commands.'); + } + await withProgress( + MongoScrapbookService.executeAllCommands(context, editor.document), + 'Executing all Mongo commands in shell...', + ); } diff --git a/src/mongo/commands/executeMongoCommand.ts b/src/mongo/commands/executeMongoCommand.ts index 834817ae9..836356f4d 100644 --- a/src/mongo/commands/executeMongoCommand.ts +++ b/src/mongo/commands/executeMongoCommand.ts @@ -4,11 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import type * as vscode from 'vscode'; -import { executeCommandFromActiveEditor } from '../MongoScrapbook'; -import { loadPersistedMongoDB } from './connectMongoDatabase'; +import * as vscode from 'vscode'; +import { withProgress } from '../../utils/withProgress'; +import { MongoScrapbookService } from '../MongoScrapbookService'; export async function executeMongoCommand(context: IActionContext, position?: vscode.Position): Promise { - await loadPersistedMongoDB(); - await executeCommandFromActiveEditor(context, position); + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error('You must open a *.mongo file to run commands.'); + } + + const pos = position ?? editor.selection.start; + + await withProgress( + MongoScrapbookService.executeCommandAtPosition(context, editor.document, pos), + 'Executing Mongo command in shell...', + ); } diff --git a/src/mongo/registerMongoCommands.ts b/src/mongo/registerMongoCommands.ts index 88b2627de..cfbca6b81 100644 --- a/src/mongo/registerMongoCommands.ts +++ b/src/mongo/registerMongoCommands.ts @@ -13,22 +13,14 @@ import { } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { ext } from '../extensionVariables'; -import { connectMongoDatabase, loadPersistedMongoDB } from './commands/connectMongoDatabase'; -import { createMongoCollection } from './commands/createMongoCollection'; -import { createMongoDatabase } from './commands/createMongoDatabase'; -import { createMongoDocument } from './commands/createMongoDocument'; +import { connectMongoDatabase } from './commands/connectMongoDatabase'; import { createMongoSrapbook } from './commands/createMongoScrapbook'; -import { deleteMongoCollection } from './commands/deleteMongoCollection'; -import { deleteMongoDB } from './commands/deleteMongoDatabase'; -import { deleteMongoDocument } from './commands/deleteMongoDocument'; import { executeAllMongoCommand } from './commands/executeAllMongoCommand'; import { executeMongoCommand } from './commands/executeMongoCommand'; -import { launchMongoShell } from './commands/launchMongoShell'; -import { openMongoCollection } from './commands/openMongoCollection'; import { MongoConnectError } from './connectToMongoClient'; import { MongoDBLanguageClient } from './languageClient'; -import { getAllErrorsFromTextDocument } from './MongoScrapbook'; -import { MongoCodeLensProvider } from './services/MongoCodeLensProvider'; +import { getAllErrorsFromTextDocument } from './MongoScrapbookHelpers'; +import { MongoScrapbookService } from './MongoScrapbookService'; let diagnosticsCollection: vscode.DiagnosticCollection; const mongoLanguageId: string = 'mongo'; @@ -36,47 +28,22 @@ const mongoLanguageId: string = 'mongo'; export function registerMongoCommands(): void { ext.mongoLanguageClient = new MongoDBLanguageClient(); - ext.mongoCodeLensProvider = new MongoCodeLensProvider(); ext.context.subscriptions.push( - vscode.languages.registerCodeLensProvider(mongoLanguageId, ext.mongoCodeLensProvider), + vscode.languages.registerCodeLensProvider(mongoLanguageId, MongoScrapbookService.getCodeLensProvider()), ); diagnosticsCollection = vscode.languages.createDiagnosticCollection('cosmosDB.mongo'); ext.context.subscriptions.push(diagnosticsCollection); setUpErrorReporting(); - void loadPersistedMongoDB(); - registerCommandWithTreeNodeUnwrapping('cosmosDB.launchMongoShell', launchMongoShell); registerCommandWithTreeNodeUnwrapping('cosmosDB.newMongoScrapbook', createMongoSrapbook); registerCommandWithTreeNodeUnwrapping('cosmosDB.executeMongoCommand', executeMongoCommand); registerCommandWithTreeNodeUnwrapping('cosmosDB.executeAllMongoCommands', executeAllMongoCommand); - // #region Account command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.createMongoDatabase', createMongoDatabase); - - // #endregion - // #region Database command registerCommandWithTreeNodeUnwrapping('cosmosDB.connectMongoDB', connectMongoDatabase); - registerCommandWithTreeNodeUnwrapping('cosmosDB.createMongoCollection', createMongoCollection); - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteMongoDB', deleteMongoDB); - - // #endregion - - // #region Collection command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.openCollection', openMongoCollection); - registerCommandWithTreeNodeUnwrapping('cosmosDB.createMongoDocument', createMongoDocument); - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteMongoCollection', deleteMongoCollection); - - // #endregion - - // #region Document command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteMongoDocument', deleteMongoDocument); // #endregion } diff --git a/src/mongo/services/MongoCodeLensProvider.ts b/src/mongo/services/MongoCodeLensProvider.ts index a9a1d8764..c3dd4982a 100644 --- a/src/mongo/services/MongoCodeLensProvider.ts +++ b/src/mongo/services/MongoCodeLensProvider.ts @@ -5,74 +5,107 @@ import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; -import { getAllCommandsFromTextDocument } from '../MongoScrapbook'; +import { getAllCommandsFromText } from '../MongoScrapbookHelpers'; +import { MongoScrapbookService } from '../MongoScrapbookService'; +/** + * Provides Code Lens functionality for the Mongo Scrapbook editor. + * + * @remarks + * This provider enables several helpful actions directly within the editor: + * + * 1. **Connection Status Lens**: + * - Displays the current database connection state (e.g., connecting, connected). + * - Offers the ability to connect to a MongoDB database if one is not yet connected. + * + * 2. **Execute All Commands Lens**: + * - Runs all detected MongoDB commands in the scrapbook document at once when triggered. + * + * 3. **Execute Single Command Lens**: + * - Appears for each individual MongoDB command found in the scrapbook. + * - Invokes execution of the command located at the specified range in the document. + */ export class MongoCodeLensProvider implements vscode.CodeLensProvider { private _onDidChangeEmitter: vscode.EventEmitter = new vscode.EventEmitter(); - private _connectedDatabase: string | undefined; - private _connectedDatabaseInitialized: boolean; + /** + * An event to signal that the code lenses from this provider have changed. + */ public get onDidChangeCodeLenses(): vscode.Event { return this._onDidChangeEmitter.event; } - public setConnectedDatabase(database: string | undefined): void { - this._connectedDatabase = database; - this._connectedDatabaseInitialized = true; + public updateCodeLens(): void { this._onDidChangeEmitter.fire(); } - public provideCodeLenses( document: vscode.TextDocument, _token: vscode.CancellationToken, ): vscode.ProviderResult { return callWithTelemetryAndErrorHandling('mongo.provideCodeLenses', (context: IActionContext) => { - // Suppress except for errors - this can fire on every keystroke context.telemetry.suppressIfSuccessful = true; - const isInitialized = this._connectedDatabaseInitialized; - const isConnected = !!this._connectedDatabase; - const database = isConnected && this._connectedDatabase; const lenses: vscode.CodeLens[] = []; - // Allow displaying and changing connected database - lenses.push({ - command: { - title: !isInitialized - ? 'Initializing...' - : isConnected - ? `Connected to ${database}` - : `Connect to a database`, - command: isInitialized && 'cosmosDB.connectMongoDB', - }, - range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)), - }); - - if (isConnected) { - // Run all - lenses.push({ - command: { - title: 'Execute All', - command: 'cosmosDB.executeAllMongoCommands', - }, - range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)), - }); - - const commands = getAllCommandsFromTextDocument(document); - for (const cmd of commands) { - // run individual - lenses.push({ - command: { - title: 'Execute', - command: 'cosmosDB.executeMongoCommand', - arguments: [cmd.range.start], - }, - range: cmd.range, - }); - } - } + // Create connection status lens + lenses.push(this.createConnectionStatusLens()); + + // Create run-all lens + lenses.push(this.createRunAllCommandsLens()); + + // Create lenses for each individual command + const commands = getAllCommandsFromText(document.getText()); + lenses.push(...this.createIndividualCommandLenses(commands)); return lenses; }); } + + private createConnectionStatusLens(): vscode.CodeLens { + const title = MongoScrapbookService.isConnected() + ? `Connected to "${MongoScrapbookService.getDisplayName()}"` + : 'Connect to a database'; + + const shortenedTitle = + title.length > 64 ? title.slice(0, 64 / 2) + '...' + title.slice(-(64 - 3 - 64 / 2)) : title; + + return { + command: { + title: '🌐 ' + shortenedTitle, + tooltip: title, + command: 'cosmosDB.connectMongoDB', + }, + range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)), + }; + } + + private createRunAllCommandsLens(): vscode.CodeLens { + const title = MongoScrapbookService.isExecutingAllCommands() ? '⏳ Running All...' : '⏩ Run All'; + + return { + command: { + title, + command: 'cosmosDB.executeAllMongoCommands', + }, + range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)), + }; + } + + private createIndividualCommandLenses(commands: { range: vscode.Range }[]): vscode.CodeLens[] { + const currentCommandInExectution = MongoScrapbookService.getSingleCommandInExecution(); + + return commands.map((cmd) => { + const running = currentCommandInExectution && cmd.range.isEqual(currentCommandInExectution.range); + const title = running ? '⏳ Running Command...' : '▶️ Run Command'; + + return { + command: { + title, + command: 'cosmosDB.executeMongoCommand', + arguments: [cmd.range.start], + }, + range: cmd.range, + }; + }); + } } diff --git a/src/mongo/setConnectedNode.ts b/src/mongo/setConnectedNode.ts index 2b7bd4944..9dac97e19 100644 --- a/src/mongo/setConnectedNode.ts +++ b/src/mongo/setConnectedNode.ts @@ -8,6 +8,6 @@ import { type MongoDatabaseTreeItem } from './tree/MongoDatabaseTreeItem'; export function setConnectedNode(node: MongoDatabaseTreeItem | undefined): void { ext.connectedMongoDB = node; - const dbName = node && node.label; - ext.mongoCodeLensProvider.setConnectedDatabase(dbName); + //const dbName = node && node.label; + //ext.mongoCodeLensProvider.setConnectedDatabase(dbName); } diff --git a/src/mongo/tree/MongoAccountTreeItem.ts b/src/mongo/tree/MongoAccountTreeItem.ts index 3834b37d8..b99092a45 100644 --- a/src/mongo/tree/MongoAccountTreeItem.ts +++ b/src/mongo/tree/MongoAccountTreeItem.ts @@ -11,13 +11,12 @@ import { parseError, type AzExtTreeItem, type IActionContext, - type ICreateChildImplContext, } from '@microsoft/vscode-azext-utils'; import { type MongoClient } from 'mongodb'; import type * as vscode from 'vscode'; import { API } from '../../AzureDBExperiences'; import { deleteCosmosDBAccount } from '../../commands/deleteDatabaseAccount/deleteCosmosDBAccount'; -import { type IDeleteWizardContext } from '../../commands/deleteDatabaseAccount/IDeleteWizardContext'; +import { type DeleteWizardContext } from '../../commands/deleteDatabaseAccount/DeleteWizardContext'; import { getThemeAgnosticIconPath, Links, testDb } from '../../constants'; import { nonNullProp } from '../../utils/nonNull'; import { connectToMongoClient } from '../connectToMongoClient'; @@ -132,17 +131,17 @@ export class MongoAccountTreeItem extends AzExtParentTreeItem { return result ?? []; } - public async createChildImpl(context: ICreateChildImplContext): Promise { - const databaseName = await context.ui.showInputBox({ - placeHolder: 'Database Name', - prompt: 'Enter the name of the database', - stepName: 'createMongoDatabase', - validateInput: validateDatabaseName, - }); - context.showCreatingTreeItem(databaseName); + // public async createChildImpl(context: ICreateChildImplContext): Promise { + // const databaseName = await context.ui.showInputBox({ + // placeHolder: 'Database Name', + // prompt: 'Enter the name of the database', + // stepName: 'createMongoDatabase', + // validateInput: validateDatabaseName, + // }); + // context.showCreatingTreeItem(databaseName); - return new MongoDatabaseTreeItem(this, databaseName, this.connectionString); - } + // return new MongoDatabaseTreeItem(this, databaseName, this.connectionString); + // } public isAncestorOfImpl(contextValue: string): boolean { switch (contextValue) { @@ -155,7 +154,7 @@ export class MongoAccountTreeItem extends AzExtParentTreeItem { } } - public async deleteTreeItemImpl(context: IDeleteWizardContext): Promise { + public async deleteTreeItemImpl(context: DeleteWizardContext): Promise { await deleteCosmosDBAccount(context, this); } } diff --git a/src/mongo/tree/MongoDatabaseTreeItem.ts b/src/mongo/tree/MongoDatabaseTreeItem.ts index a36df8026..598635531 100644 --- a/src/mongo/tree/MongoDatabaseTreeItem.ts +++ b/src/mongo/tree/MongoDatabaseTreeItem.ts @@ -23,7 +23,7 @@ import { nonNullProp, nonNullValue } from '../../utils/nonNull'; import { connectToMongoClient } from '../connectToMongoClient'; import { type MongoCommand } from '../MongoCommand'; import { addDatabaseToAccountConnectionString } from '../mongoConnectionStrings'; -import { MongoShell } from '../MongoShell'; +import { MongoShellScriptRunner } from '../MongoShellScriptRunner'; import { type IMongoTreeRoot } from './IMongoTreeRoot'; import { type MongoAccountTreeItem } from './MongoAccountTreeItem'; import { MongoCollectionTreeItem } from './MongoCollectionTreeItem'; @@ -168,7 +168,7 @@ export class MongoDatabaseTreeItem extends AzExtParentTreeItem { } } - private async createShell(context: IActionContext): Promise { + private async createShell(context: IActionContext): Promise { const config = vscode.workspace.getConfiguration(); let shellPath: string | undefined = config.get(ext.settingsKeys.mongoShellPath); const shellArgs: string[] = config.get(ext.settingsKeys.mongoShellArgs, []); @@ -182,7 +182,7 @@ export class MongoDatabaseTreeItem extends AzExtParentTreeItem { const timeout = 1000 * nonNullValue(config.get(ext.settingsKeys.mongoShellTimeout), 'mongoShellTimeout'); - return MongoShell.create( + return MongoShellScriptRunner.createShellProcessHelper( shellPath, shellArgs, this.connectionString, diff --git a/src/mongoClusters/CredentialCache.ts b/src/mongoClusters/CredentialCache.ts index d79c59457..acdf5877d 100644 --- a/src/mongoClusters/CredentialCache.ts +++ b/src/mongoClusters/CredentialCache.ts @@ -7,7 +7,7 @@ import { addAuthenticationDataToConnectionString } from './utils/connectionStrin export interface MongoClustersCredentials { mongoClusterId: string; - connectionStringWithPassword?: string; // wipe it after use + connectionStringWithPassword?: string; connectionString: string; connectionUser: string; } diff --git a/src/mongoClusters/MongoClustersClient.ts b/src/mongoClusters/MongoClustersClient.ts index 3df82ca9e..3cdf1cac3 100644 --- a/src/mongoClusters/MongoClustersClient.ts +++ b/src/mongoClusters/MongoClustersClient.ts @@ -130,9 +130,16 @@ export class MongoClustersClient { return CredentialCache.getCredentials(this._credentialId)?.connectionString; } + getConnectionStringWithPassword() { + return CredentialCache.getConnectionStringWithPassword(this._credentialId); + } + async listDatabases(): Promise { const rawDatabases: ListDatabasesResult = await this._mongoClient.db().admin().listDatabases(); - const databases: DatabaseItemModel[] = rawDatabases.databases; + const databases: DatabaseItemModel[] = rawDatabases.databases.filter( + // Filter out the 'admin' database if it's empty + (databaseInfo) => !(databaseInfo.name && databaseInfo.name.toLowerCase() === 'admin' && databaseInfo.empty), + ); return databases; } @@ -356,7 +363,7 @@ export class MongoClustersClient { const newCollection = await this._mongoClient .db(databaseName) .createCollection('_dummy_collection_creation_forces_db_creation'); - await newCollection.drop(); + await newCollection.drop({ writeConcern: { w: 'majority', wtimeout: 5000 } }); } catch (_e) { console.error(_e); //todo: add to telemetry return false; diff --git a/src/mongoClusters/MongoClustersExtension.ts b/src/mongoClusters/MongoClustersExtension.ts index 3156e9094..967209a98 100644 --- a/src/mongoClusters/MongoClustersExtension.ts +++ b/src/mongoClusters/MongoClustersExtension.ts @@ -18,19 +18,17 @@ import { import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; import * as vscode from 'vscode'; import { ext } from '../extensionVariables'; -import { - SharedWorkspaceResourceProvider, - WorkspaceResourceType, -} from '../tree/workspace/sharedWorkspaceResourceProvider'; +import { WorkspaceResourceType } from '../tree/workspace/SharedWorkspaceResourceProvider'; import { addWorkspaceConnection } from './commands/addWorkspaceConnection'; import { createCollection } from './commands/createCollection'; import { createDatabase } from './commands/createDatabase'; +import { createDocument } from './commands/createDocument'; import { dropCollection } from './commands/dropCollection'; import { dropDatabase } from './commands/dropDatabase'; import { mongoClustersExportEntireCollection, mongoClustersExportQueryResults } from './commands/exportDocuments'; import { mongoClustersImportDocuments } from './commands/importDocuments'; import { launchShell } from './commands/launchShell'; -import { openCollectionView } from './commands/openCollectionView'; +import { openCollectionView, openCollectionViewInternal } from './commands/openCollectionView'; import { openDocumentView } from './commands/openDocumentView'; import { removeWorkspaceConnection } from './commands/removeWorkspaceConnection'; import { MongoClustersBranchDataProvider } from './tree/MongoClustersBranchDataProvider'; @@ -71,8 +69,9 @@ export class MongoClustersExtension implements vscode.Disposable { ext.mongoClustersBranchDataProvider, ); - ext.workspaceDataProvider = new SharedWorkspaceResourceProvider(); - ext.rgApiV2.resources.registerWorkspaceResourceProvider(ext.workspaceDataProvider); + // Moved to extension.ts + // ext.workspaceDataProvider = new SharedWorkspaceResourceProvider(); + // ext.rgApiV2.resources.registerWorkspaceResourceProvider(ext.workspaceDataProvider); ext.mongoClustersWorkspaceBranchDataProvider = new MongoClustersWorkspaceBranchDataProvider(); ext.rgApiV2.resources.registerWorkspaceResourceBranchDataProvider( @@ -83,7 +82,19 @@ export class MongoClustersExtension implements vscode.Disposable { // using registerCommand instead of vscode.commands.registerCommand for better telemetry: // https://github.com/microsoft/vscode-azuretools/tree/main/utils#telemetry-and-error-handling - registerCommand('command.internal.mongoClusters.containerView.open', openCollectionView); + /** + * Here, opening the collection view is done in two ways: one is accessible from the tree view + * via a context menu, and the other is accessible programmatically. Both of them + * use the same underlying function to open the collection view. + * + * openCollectionView calls openCollectionViewInternal with no additional parameters. + * + * It was possible to merge the two commands into one, but it would result in code that is + * harder to understand and maintain. + */ + registerCommand('command.internal.mongoClusters.containerView.open', openCollectionViewInternal); + registerCommandWithTreeNodeUnwrapping('command.mongoClusters.containerView.open', openCollectionView); + registerCommand('command.internal.mongoClusters.documentView.open', openDocumentView); registerCommandWithTreeNodeUnwrapping('command.mongoClusters.launchShell', launchShell); @@ -94,6 +105,8 @@ export class MongoClustersExtension implements vscode.Disposable { registerCommandWithTreeNodeUnwrapping('command.mongoClusters.createCollection', createCollection); registerCommandWithTreeNodeUnwrapping('command.mongoClusters.createDatabase', createDatabase); + registerCommandWithTreeNodeUnwrapping('command.mongoClusters.createDocument', createDocument); + registerCommandWithTreeNodeUnwrapping( 'command.mongoClusters.importDocuments', mongoClustersImportDocuments, diff --git a/src/mongoClusters/commands/addWorkspaceConnection.ts b/src/mongoClusters/commands/addWorkspaceConnection.ts index a6ff01ad2..610d8e462 100644 --- a/src/mongoClusters/commands/addWorkspaceConnection.ts +++ b/src/mongoClusters/commands/addWorkspaceConnection.ts @@ -8,8 +8,8 @@ import ConnectionString from 'mongodb-connection-string-url'; import * as vscode from 'vscode'; import { API } from '../../AzureDBExperiences'; import { ext } from '../../extensionVariables'; -import { WorkspaceResourceType } from '../../tree/workspace/sharedWorkspaceResourceProvider'; -import { SharedWorkspaceStorage } from '../../tree/workspace/sharedWorkspaceStorage'; +import { WorkspaceResourceType } from '../../tree/workspace/SharedWorkspaceResourceProvider'; +import { SharedWorkspaceStorage } from '../../tree/workspace/SharedWorkspaceStorage'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; import { localize } from '../../utils/localize'; import { areMongoDBRU } from '../utils/connectionStringHelpers'; diff --git a/src/mongoClusters/commands/createCollection.ts b/src/mongoClusters/commands/createCollection.ts index 59d5fa123..7f60acf83 100644 --- a/src/mongoClusters/commands/createCollection.ts +++ b/src/mongoClusters/commands/createCollection.ts @@ -11,6 +11,8 @@ import { type CreateCollectionWizardContext } from '../wizards/create/createWiza import { CollectionNameStep } from '../wizards/create/PromptCollectionNameStep'; export async function createCollection(context: IActionContext, databaseNode?: DatabaseItem): Promise { + context.telemetry.properties.experience = databaseNode?.mongoCluster.dbExperience?.api; + // node ??= ... pick a node if not provided if (!databaseNode) { throw new Error('No database selected.'); diff --git a/src/mongoClusters/commands/createDatabase.ts b/src/mongoClusters/commands/createDatabase.ts index a949e2c29..8e71e034d 100644 --- a/src/mongoClusters/commands/createDatabase.ts +++ b/src/mongoClusters/commands/createDatabase.ts @@ -4,36 +4,57 @@ *--------------------------------------------------------------------------------------------*/ import { AzureWizard, nonNullValue, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { MongoAccountResourceItem } from '../../tree/mongo/MongoAccountResourceItem'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; import { localize } from '../../utils/localize'; import { CredentialCache } from '../CredentialCache'; -import { type MongoClusterResourceItem } from '../tree/MongoClusterResourceItem'; +import { MongoClusterItemBase } from '../tree/MongoClusterItemBase'; import { type CreateCollectionWizardContext, type CreateDatabaseWizardContext, } from '../wizards/create/createWizardContexts'; import { DatabaseNameStep } from '../wizards/create/PromptDatabaseNameStep'; -export async function createDatabase(context: IActionContext, clusterNode?: MongoClusterResourceItem): Promise { +export async function createDatabase( + context: IActionContext, + clusterNode?: MongoClusterItemBase | MongoAccountResourceItem, +): Promise { // node ??= ... pick a node if not provided if (!clusterNode) { throw new Error('No cluster selected.'); } - if (!CredentialCache.hasCredentials(clusterNode.mongoCluster.id)) { + let connectionId: string = ''; + let clusterName: string = ''; + + // TODO: currently MongoAccountResourceItem does not reuse MongoClusterItemBase, this will be refactored after the v1 to v2 tree migration + + if (clusterNode instanceof MongoAccountResourceItem) { + context.telemetry.properties.experience = clusterNode.experience?.api; + connectionId = clusterNode.id; + clusterName = clusterNode.account.name; + } + + if (clusterNode instanceof MongoClusterItemBase) { + context.telemetry.properties.experience = clusterNode.mongoCluster.dbExperience?.api; + connectionId = clusterNode.mongoCluster.id; + clusterName = clusterNode.mongoCluster.name; + } + + if (!CredentialCache.hasCredentials(connectionId)) { throw new Error( localize( 'mongoClusters.notSignedIn', 'You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node "{0}") and try again.', - clusterNode.mongoCluster.name, + clusterName, ), ); } const wizardContext: CreateDatabaseWizardContext = { ...context, - credentialsId: clusterNode.mongoCluster.id, - mongoClusterItem: clusterNode, + credentialsId: connectionId, + clusterName: clusterName, }; const wizard: AzureWizard = new AzureWizard(wizardContext, { diff --git a/src/mongoClusters/commands/createDocument.ts b/src/mongoClusters/commands/createDocument.ts new file mode 100644 index 000000000..5e994e1e1 --- /dev/null +++ b/src/mongoClusters/commands/createDocument.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type CollectionItem } from '../tree/CollectionItem'; + +import * as vscode from 'vscode'; + +export async function createDocument(context: IActionContext, node?: CollectionItem): Promise { + context.telemetry.properties.experience = node?.mongoCluster.dbExperience?.api; + + // node ??= ... pick a node if not provided + if (!node) { + throw new Error('No collection selected.'); + } + + await vscode.commands.executeCommand('command.internal.mongoClusters.documentView.open', { + clusterId: node.mongoCluster.id, + databaseName: node.databaseInfo.name, + collectionName: node.collectionInfo.name, + mode: 'add', + }); +} diff --git a/src/mongoClusters/commands/dropCollection.ts b/src/mongoClusters/commands/dropCollection.ts index 9499829ca..1773fa4ed 100644 --- a/src/mongoClusters/commands/dropCollection.ts +++ b/src/mongoClusters/commands/dropCollection.ts @@ -10,6 +10,8 @@ import { localize } from '../../utils/localize'; import { type CollectionItem } from '../tree/CollectionItem'; export async function dropCollection(context: IActionContext, node?: CollectionItem): Promise { + context.telemetry.properties.experience = node?.mongoCluster.dbExperience?.api; + // node ??= ... pick a node if not provided if (!node) { throw new Error('No collection selected.'); diff --git a/src/mongoClusters/commands/dropDatabase.ts b/src/mongoClusters/commands/dropDatabase.ts index 52f3b860b..62856a105 100644 --- a/src/mongoClusters/commands/dropDatabase.ts +++ b/src/mongoClusters/commands/dropDatabase.ts @@ -10,6 +10,8 @@ import { localize } from '../../utils/localize'; import { type DatabaseItem } from '../tree/DatabaseItem'; export async function dropDatabase(context: IActionContext, node?: DatabaseItem): Promise { + context.telemetry.properties.experience = node?.mongoCluster.dbExperience?.api; + // node ??= ... pick a node if not provided if (!node) { throw new Error('No database selected.'); diff --git a/src/mongoClusters/commands/exportDocuments.ts b/src/mongoClusters/commands/exportDocuments.ts index f885da3b9..f166a1b2a 100644 --- a/src/mongoClusters/commands/exportDocuments.ts +++ b/src/mongoClusters/commands/exportDocuments.ts @@ -13,6 +13,8 @@ import { MongoClustersClient } from '../MongoClustersClient'; import { type CollectionItem } from '../tree/CollectionItem'; export async function mongoClustersExportEntireCollection(context: IActionContext, node?: CollectionItem) { + context.telemetry.properties.experience = node?.mongoCluster.dbExperience?.api; + return mongoClustersExportQueryResults(context, node); } @@ -21,6 +23,8 @@ export async function mongoClustersExportQueryResults( node?: CollectionItem, props?: { queryText?: string; source?: string }, ): Promise { + context.telemetry.properties.experience = node?.mongoCluster.dbExperience?.api; + // node ??= ... pick a node if not provided if (!node) { throw new Error('No collection selected.'); diff --git a/src/mongoClusters/commands/importDocuments.ts b/src/mongoClusters/commands/importDocuments.ts index 5b4250867..4e3d0c948 100644 --- a/src/mongoClusters/commands/importDocuments.ts +++ b/src/mongoClusters/commands/importDocuments.ts @@ -13,6 +13,8 @@ export async function mongoClustersImportDocuments( _collectionNodes?: CollectionItem[], // required by the TreeNodeCommandCallback, but not used ...args: unknown[] ): Promise { + context.telemetry.properties.experience = collectionNode?.mongoCluster.dbExperience?.api; + const source = (args[0] as { source?: string })?.source || 'contextMenu'; context.telemetry.properties.calledFrom = source; diff --git a/src/mongoClusters/commands/launchShell.ts b/src/mongoClusters/commands/launchShell.ts index 92e999369..ef90a966f 100644 --- a/src/mongoClusters/commands/launchShell.ts +++ b/src/mongoClusters/commands/launchShell.ts @@ -3,55 +3,92 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { nonNullValue, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; +import { ext } from '../../extensionVariables'; +import { MongoAccountResourceItem } from '../../tree/mongo/MongoAccountResourceItem'; import { MongoClustersClient } from '../MongoClustersClient'; import { type CollectionItem } from '../tree/CollectionItem'; import { type DatabaseItem } from '../tree/DatabaseItem'; -import { MongoClusterItemBase } from '../tree/MongoClusterItemBase'; -import { type MongoClusterResourceItem } from '../tree/MongoClusterResourceItem'; -import { - addAuthenticationDataToConnectionString, - addDatabasePathToConnectionString, -} from '../utils/connectionStringHelpers'; +import { MongoClusterResourceItem } from '../tree/MongoClusterResourceItem'; +import { MongoClusterWorkspaceItem } from '../tree/workspace/MongoClusterWorkspaceItem'; + +import { ConnectionString } from 'mongodb-connection-string-url'; export async function launchShell( - _context: IActionContext, - node?: DatabaseItem | CollectionItem | MongoClusterResourceItem, + context: IActionContext, + node?: + | DatabaseItem + | CollectionItem + | MongoClusterWorkspaceItem + | MongoClusterResourceItem + | MongoAccountResourceItem, ): Promise { if (!node) { throw new Error('No database or collection selected.'); } - const client: MongoClustersClient = await MongoClustersClient.getClient(node.mongoCluster.id); + let rawConnectionString: string | undefined; + + // connection string discovery for these items can be slow, so we need to run it with a temporary description + if (node instanceof MongoClusterResourceItem || node instanceof MongoAccountResourceItem) { + rawConnectionString = await ext.state.runWithTemporaryDescription(node.id, 'Working...', async () => { + if (node instanceof MongoAccountResourceItem) { + context.telemetry.properties.experience = node.experience?.api; + return node.discoverConnectionString(); + } + + if (node instanceof MongoClusterResourceItem) { + context.telemetry.properties.experience = node.mongoCluster.dbExperience?.api; + return node.discoverConnectionString(); + } + + return undefined; + }); + // WorkspaceItems are fast as there is no connnestion string discovery happening + } else if (node instanceof MongoClusterWorkspaceItem) { + context.telemetry.properties.experience = node.mongoCluster.dbExperience?.api; + rawConnectionString = await node.discoverConnectionString(); + // TODO: add an entry work mongodb workspaceitem once ready + } // everything else has the connection string available in memory as we're connected to the server + else { + context.telemetry.properties.experience = node.experience?.api; + const client: MongoClustersClient = await MongoClustersClient.getClient(node.mongoCluster.id); + rawConnectionString = client.getConnectionStringWithPassword(); + } + + if (!rawConnectionString) { + void vscode.window.showErrorMessage('Failed to extract the connection string from the selected cluster.'); + return; + } - const connectionString = client.getConnectionString(); - const username = client.getUserName(); + const connectionString: ConnectionString = new ConnectionString(rawConnectionString); - const connectionStringWithUserName = addAuthenticationDataToConnectionString( - nonNullValue(connectionString), - nonNullValue(username), - undefined, - ); + const username = connectionString.username; + const password = connectionString.password; - let shellParameters = ''; + const isWindows = process.platform === 'win32'; + connectionString.username = isWindows ? '%USERNAME%' : '$USERNAME'; + connectionString.password = isWindows ? '%PASSWORD%' : '$PASSWORD'; - if (node instanceof MongoClusterItemBase) { - shellParameters = `"${connectionStringWithUserName}"`; - } /*if (node instanceof DatabaseItem)*/ else { - const connStringWithDb = addDatabasePathToConnectionString( - connectionStringWithUserName, - node.databaseInfo.name, - ); - shellParameters = `"${connStringWithDb}"`; + if ('databaseInfo' in node && node.databaseInfo?.name) { + connectionString.pathname = node.databaseInfo.name; } + // } else if (node instanceof CollectionItem) { // --> --eval terminates, we'd have to launch with a script etc. let's look into it latter // const connStringWithDb = addDatabasePathToConnectionString(connectionStringWithUserName, node.databaseInfo.name); // shellParameters = `"${connStringWithDb}" --eval 'db.getCollection("${node.collectionInfo.name}")'` // } - const terminal: vscode.Terminal = vscode.window.createTerminal('MongoDB Clusters Shell'); + const terminal: vscode.Terminal = vscode.window.createTerminal({ + name: `MongoDB Shell (${username})`, + hideFromUser: false, + env: { + USERNAME: username, + PASSWORD: password, + }, + }); - terminal.sendText('mongosh ' + shellParameters); + terminal.sendText(`mongosh "${connectionString.toString()}"`); terminal.show(); } diff --git a/src/mongoClusters/commands/openCollectionView.ts b/src/mongoClusters/commands/openCollectionView.ts index 560fdd0dc..80de2a7ef 100644 --- a/src/mongoClusters/commands/openCollectionView.ts +++ b/src/mongoClusters/commands/openCollectionView.ts @@ -8,11 +8,27 @@ import { CollectionViewController } from '../../webviews/mongoClusters/collectio import { MongoClustersSession } from '../MongoClusterSession'; import { type CollectionItem } from '../tree/CollectionItem'; -export async function openCollectionView( +export async function openCollectionView(context: IActionContext, node?: CollectionItem) { + if (!node) { + throw new Error('Invalid collection node'); + } + + context.telemetry.properties.experience = node?.mongoCluster.dbExperience?.api; + + return openCollectionViewInternal(context, { + id: node.id, + clusterId: node.mongoCluster.id, + databaseName: node.databaseInfo.name, + collectionName: node.collectionInfo.name, + collectionTreeItem: node, + }); +} + +export async function openCollectionViewInternal( _context: IActionContext, props: { id: string; - liveConnectionId: string; + clusterId: string; databaseName: string; collectionName: string; collectionTreeItem: CollectionItem; @@ -22,12 +38,13 @@ export async function openCollectionView( * We're starting a new "session" using the existing connection. * A session can cache data, handle paging, and convert data. */ - const sessionId = await MongoClustersSession.initNewSession(props.liveConnectionId); + const sessionId = await MongoClustersSession.initNewSession(props.clusterId); const view = new CollectionViewController({ id: props.id, sessionId: sessionId, + clusterId: props.clusterId, databaseName: props.databaseName, collectionName: props.collectionName, collectionTreeItem: props.collectionTreeItem, diff --git a/src/mongoClusters/commands/openDocumentView.ts b/src/mongoClusters/commands/openDocumentView.ts index 65d6b5b86..2824b5859 100644 --- a/src/mongoClusters/commands/openDocumentView.ts +++ b/src/mongoClusters/commands/openDocumentView.ts @@ -12,7 +12,7 @@ export function openDocumentView( props: { id: string; - sessionId: string; + clusterId: string; databaseName: string; collectionName: string; documentId: string; @@ -23,7 +23,7 @@ export function openDocumentView( const view = new DocumentsViewController({ id: props.id, - sessionId: props.sessionId, + clusterId: props.clusterId, databaseName: props.databaseName, collectionName: props.collectionName, documentId: props.documentId, diff --git a/src/mongoClusters/commands/removeWorkspaceConnection.ts b/src/mongoClusters/commands/removeWorkspaceConnection.ts index 123299ed3..18ed126d5 100644 --- a/src/mongoClusters/commands/removeWorkspaceConnection.ts +++ b/src/mongoClusters/commands/removeWorkspaceConnection.ts @@ -5,8 +5,8 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import { ext } from '../../extensionVariables'; -import { WorkspaceResourceType } from '../../tree/workspace/sharedWorkspaceResourceProvider'; -import { SharedWorkspaceStorage } from '../../tree/workspace/sharedWorkspaceStorage'; +import { WorkspaceResourceType } from '../../tree/workspace/SharedWorkspaceResourceProvider'; +import { SharedWorkspaceStorage } from '../../tree/workspace/SharedWorkspaceStorage'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; import { localize } from '../../utils/localize'; import { type MongoClusterWorkspaceItem } from '../tree/workspace/MongoClusterWorkspaceItem'; diff --git a/src/mongoClusters/tree/CollectionItem.ts b/src/mongoClusters/tree/CollectionItem.ts index 672ed39dd..bdca220d6 100644 --- a/src/mongoClusters/tree/CollectionItem.ts +++ b/src/mongoClusters/tree/CollectionItem.ts @@ -3,21 +3,34 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createGenericElement, type IActionContext, type TreeElementBase } from '@microsoft/vscode-azext-utils'; +import { + createContextValue, + createGenericElement, + type IActionContext, + type TreeElementBase, + type TreeElementWithId, +} from '@microsoft/vscode-azext-utils'; import { type Document } from 'bson'; -import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from 'vscode'; +import { ThemeIcon, type TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { API, type Experience } from '../../AzureDBExperiences'; import { ext } from '../../extensionVariables'; +import { type TreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../../tree/TreeElementWithExperience'; import { - MongoClustersClient, type CollectionItemModel, type DatabaseItemModel, type InsertDocumentsResult, + MongoClustersClient, } from '../MongoClustersClient'; import { IndexesItem } from './IndexesItem'; import { type MongoClusterModel } from './MongoClusterModel'; -export class CollectionItem { - id: string; +export class CollectionItem implements TreeElementWithId, TreeElementWithExperience, TreeElementWithContextValue { + public readonly id: string; + public readonly experience?: Experience; + public readonly contextValue: string = 'treeItem.collection'; + + private readonly experienceContextValue: string = ''; constructor( readonly mongoCluster: MongoClusterModel, @@ -25,12 +38,15 @@ export class CollectionItem { readonly collectionInfo: CollectionItemModel, ) { this.id = `${mongoCluster.id}/${databaseInfo.name}/${collectionInfo.name}`; + this.experience = mongoCluster.dbExperience; + this.experienceContextValue = `experience.${this.experience?.api ?? API.Common}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } async getChildren(): Promise { return [ createGenericElement({ - contextValue: 'mongoClusters.item.documents', + contextValue: createContextValue(['treeItem.documents', this.experienceContextValue]), id: `${this.id}/documents`, label: 'Documents', commandId: 'command.internal.mongoClusters.containerView.open', @@ -40,7 +56,7 @@ export class CollectionItem { viewTitle: `${this.collectionInfo.name}`, // viewTitle: `${this.mongoCluster.name}/${this.databaseInfo.name}/${this.collectionInfo.name}`, // using '/' as a separator to use VSCode's "title compression"(?) feature - liveConnectionId: this.mongoCluster.id, + clusterId: this.mongoCluster.id, databaseName: this.databaseInfo.name, collectionName: this.collectionInfo.name, collectionTreeItem: this, @@ -80,7 +96,7 @@ export class CollectionItem { getTreeItem(): TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.collection', + contextValue: this.contextValue, label: this.collectionInfo.name, iconPath: new ThemeIcon('folder-opened'), collapsibleState: TreeItemCollapsibleState.Collapsed, diff --git a/src/mongoClusters/tree/DatabaseItem.ts b/src/mongoClusters/tree/DatabaseItem.ts index 12c85180d..a66b80818 100644 --- a/src/mongoClusters/tree/DatabaseItem.ts +++ b/src/mongoClusters/tree/DatabaseItem.ts @@ -3,23 +3,39 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createGenericElement, type IActionContext, type TreeElementBase } from '@microsoft/vscode-azext-utils'; +import { + createContextValue, + createGenericElement, + type IActionContext, + type TreeElementBase, + type TreeElementWithId, +} from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from 'vscode'; +import { API, type Experience } from '../../AzureDBExperiences'; import { ext } from '../../extensionVariables'; +import { type TreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../../tree/TreeElementWithExperience'; import { localize } from '../../utils/localize'; import { MongoClustersClient, type DatabaseItemModel } from '../MongoClustersClient'; import { CollectionItem } from './CollectionItem'; import { type MongoClusterModel } from './MongoClusterModel'; -export class DatabaseItem { - id: string; +export class DatabaseItem implements TreeElementWithId, TreeElementWithExperience, TreeElementWithContextValue { + public readonly id: string; + public readonly experience?: Experience; + public readonly contextValue: string = 'treeItem.database'; + + private readonly experienceContextValue: string = ''; constructor( readonly mongoCluster: MongoClusterModel, readonly databaseInfo: DatabaseItemModel, ) { this.id = `${mongoCluster.id}/${databaseInfo.name}`; + this.experience = mongoCluster.dbExperience; + this.experienceContextValue = `experience.${this.experience?.api ?? API.Common}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } async getChildren(): Promise { @@ -30,8 +46,8 @@ export class DatabaseItem { // no databases in there: return [ createGenericElement({ - contextValue: 'mongoClusters.item.no-collection', - id: `${this.id}/no-databases`, + contextValue: createContextValue(['treeItem.no-collections', this.experienceContextValue]), + id: `${this.id}/no-collections`, label: 'Create collection...', iconPath: new vscode.ThemeIcon('plus'), commandId: 'command.mongoClusters.createCollection', @@ -61,25 +77,29 @@ export class DatabaseItem { async createCollection(_context: IActionContext, collectionName: string): Promise { const client = await MongoClustersClient.getClient(this.mongoCluster.id); - let success = false; - - await ext.state.showCreatingChild( + return ext.state.showCreatingChild( this.id, localize('mongoClusters.tree.creating', 'Creating "{0}"...', collectionName), async () => { - success = await client.createCollection(this.databaseInfo.name, collectionName); + // Adding a delay to ensure the "creating child" animation is visible. + // The `showCreatingChild` function refreshes the parent to show the + // "creating child" animation and label. Refreshing the parent triggers its + // `getChildren` method. If the database creation completes too quickly, + // the dummy node with the animation might be shown alongside the actual + // database entry, as it will already be available in the database. + // Note to future maintainers: Do not remove this delay. + await new Promise((resolve) => setTimeout(resolve, 250)); + return client.createCollection(this.databaseInfo.name, collectionName); }, ); - - return success; } getTreeItem(): TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.database', + contextValue: this.contextValue, label: this.databaseInfo.name, - iconPath: new ThemeIcon('database'), // TODO: create our onw icon here, this one's shape can change + iconPath: new ThemeIcon('database'), // TODO: create our own icon here, this one's shape can change collapsibleState: TreeItemCollapsibleState.Collapsed, }; } diff --git a/src/mongoClusters/tree/IndexItem.ts b/src/mongoClusters/tree/IndexItem.ts index b42fb5f73..d4bd544c2 100644 --- a/src/mongoClusters/tree/IndexItem.ts +++ b/src/mongoClusters/tree/IndexItem.ts @@ -3,13 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createGenericElement, type TreeElementBase } from '@microsoft/vscode-azext-utils'; +import { + createContextValue, + createGenericElement, + type TreeElementBase, + type TreeElementWithId, +} from '@microsoft/vscode-azext-utils'; import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from 'vscode'; +import { API, type Experience } from '../../AzureDBExperiences'; +import { type TreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../../tree/TreeElementWithExperience'; import { type CollectionItemModel, type DatabaseItemModel, type IndexItemModel } from '../MongoClustersClient'; import { type MongoClusterModel } from './MongoClusterModel'; -export class IndexItem { - id: string; +export class IndexItem implements TreeElementWithId, TreeElementWithExperience, TreeElementWithContextValue { + public readonly id: string; + public readonly experience?: Experience; + public readonly contextValue: string = 'treeItem.index'; + + private readonly experienceContextValue: string = ''; constructor( readonly mongoCluster: MongoClusterModel, @@ -18,6 +30,9 @@ export class IndexItem { readonly indexInfo: IndexItemModel, ) { this.id = `${mongoCluster.id}/${databaseInfo.name}/${collectionInfo.name}/indexes/${indexInfo.name}`; + this.experience = mongoCluster.dbExperience; + this.experienceContextValue = `experience.${this.experience?.api ?? API.Common}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } async getChildren(): Promise { @@ -38,7 +53,7 @@ export class IndexItem { getTreeItem(): TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.index', + contextValue: this.contextValue, label: this.indexInfo.name, iconPath: new ThemeIcon('combine'), // TODO: create our onw icon here, this one's shape can change collapsibleState: TreeItemCollapsibleState.Collapsed, diff --git a/src/mongoClusters/tree/IndexesItem.ts b/src/mongoClusters/tree/IndexesItem.ts index deb059f89..303bcaf60 100644 --- a/src/mongoClusters/tree/IndexesItem.ts +++ b/src/mongoClusters/tree/IndexesItem.ts @@ -3,14 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type TreeElementBase } from '@microsoft/vscode-azext-utils'; +import { createContextValue, type TreeElementBase, type TreeElementWithId } from '@microsoft/vscode-azext-utils'; import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from 'vscode'; +import { API, type Experience } from '../../AzureDBExperiences'; +import { type TreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../../tree/TreeElementWithExperience'; import { MongoClustersClient, type CollectionItemModel, type DatabaseItemModel } from '../MongoClustersClient'; import { IndexItem } from './IndexItem'; import { type MongoClusterModel } from './MongoClusterModel'; -export class IndexesItem { - id: string; +export class IndexesItem implements TreeElementWithId, TreeElementWithExperience, TreeElementWithContextValue { + public readonly id: string; + public readonly experience?: Experience; + public readonly contextValue: string = 'treeItem.indexes'; + + private readonly experienceContextValue: string = ''; constructor( readonly mongoCluster: MongoClusterModel, @@ -18,6 +25,9 @@ export class IndexesItem { readonly collectionInfo: CollectionItemModel, ) { this.id = `${mongoCluster.id}/${databaseInfo.name}/${collectionInfo.name}/indexes`; + this.experience = mongoCluster.dbExperience; + this.experienceContextValue = `experience.${this.experience?.api ?? API.Common}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } async getChildren(): Promise { @@ -31,7 +41,7 @@ export class IndexesItem { getTreeItem(): TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.indexes', + contextValue: this.contextValue, label: 'Indexes', iconPath: new ThemeIcon('combine'), // TODO: create our onw icon here, this one's shape can change collapsibleState: TreeItemCollapsibleState.Collapsed, diff --git a/src/mongoClusters/tree/MongoClusterItemBase.ts b/src/mongoClusters/tree/MongoClusterItemBase.ts index 5b2b96681..24e474b3f 100644 --- a/src/mongoClusters/tree/MongoClusterItemBase.ts +++ b/src/mongoClusters/tree/MongoClusterItemBase.ts @@ -3,11 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createGenericElement, type IActionContext, type TreeElementBase } from '@microsoft/vscode-azext-utils'; +import { + createContextValue, + createGenericElement, + type IActionContext, + type TreeElementBase, + type TreeElementWithId, +} from '@microsoft/vscode-azext-utils'; import { type TreeItem } from 'vscode'; import * as vscode from 'vscode'; +import { API, type Experience } from '../../AzureDBExperiences'; import { ext } from '../../extensionVariables'; +import { type TreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../../tree/TreeElementWithExperience'; import { localize } from '../../utils/localize'; import { regionToDisplayName } from '../../utils/regionToDisplayName'; import { CredentialCache } from '../CredentialCache'; @@ -16,14 +25,20 @@ import { DatabaseItem } from './DatabaseItem'; import { type MongoClusterModel } from './MongoClusterModel'; // This info will be available at every level in the tree for immediate access -export abstract class MongoClusterItemBase implements TreeElementBase { - id: string; +export abstract class MongoClusterItemBase + implements TreeElementWithId, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly experience?: Experience; + public readonly contextValue: string = 'treeItem.mongoCluster'; - constructor( - // public readonly subscription: AzureSubscription, - public mongoCluster: MongoClusterModel, - ) { + private readonly experienceContextValue: string = ''; + + protected constructor(public mongoCluster: MongoClusterModel) { this.id = mongoCluster.id ?? ''; + this.experience = mongoCluster.dbExperience; + this.experienceContextValue = `experience.${this.experience?.api ?? API.Common}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } /** @@ -35,6 +50,14 @@ export abstract class MongoClusterItemBase implements TreeElementBase { */ protected abstract authenticateAndConnect(): Promise; + /** + * Abstract method to get the connection string for the MongoDB cluster. + * Must be implemented by subclasses. + * + * @returns A promise that resolves to the connection string if successful; otherwise, undefined. + */ + public abstract discoverConnectionString(): Promise; + /** * Authenticates and connects to the cluster to list all available databases. * Here, the MongoDB client is created and cached for future use. @@ -82,7 +105,7 @@ export abstract class MongoClusterItemBase implements TreeElementBase { if (databases.length === 0) { return [ createGenericElement({ - contextValue: 'mongoClusters.item.no-databases', + contextValue: createContextValue(['treeItem.no-databases', this.experienceContextValue]), id: `${this.id}/no-databases`, label: 'Create database...', iconPath: new vscode.ThemeIcon('plus'), @@ -106,17 +129,22 @@ export abstract class MongoClusterItemBase implements TreeElementBase { async createDatabase(_context: IActionContext, databaseName: string): Promise { const client = await MongoClustersClient.getClient(this.mongoCluster.id); - let success = false; - - await ext.state.showCreatingChild( + return ext.state.showCreatingChild( this.id, localize('mongoClusters.tree.creating', 'Creating "{0}"...', databaseName), - async () => { - success = await client.createDatabase(databaseName); + async (): Promise => { + // Adding a delay to ensure the "creating child" animation is visible. + // The `showCreatingChild` function refreshes the parent to show the + // "creating child" animation and label. Refreshing the parent triggers its + // `getChildren` method. If the database creation completes too quickly, + // the dummy node with the animation might be shown alongside the actual + // database entry, as it will already be available in the database. + // Note to future maintainers: Do not remove this delay. + await new Promise((resolve) => setTimeout(resolve, 250)); + + return client.createDatabase(databaseName); }, ); - - return success; } /** @@ -126,7 +154,7 @@ export abstract class MongoClusterItemBase implements TreeElementBase { getTreeItem(): TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.mongoCluster', + contextValue: this.contextValue, label: this.mongoCluster.name, description: this.mongoCluster.sku !== undefined ? `(${this.mongoCluster.sku})` : false, // iconPath: getThemeAgnosticIconPath('CosmosDBAccount.svg'), // Uncomment if icon is available diff --git a/src/mongoClusters/tree/MongoClusterModel.ts b/src/mongoClusters/tree/MongoClusterModel.ts index 0ed3993ad..702cc1165 100644 --- a/src/mongoClusters/tree/MongoClusterModel.ts +++ b/src/mongoClusters/tree/MongoClusterModel.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { type MongoCluster, type Resource } from '@azure/arm-cosmosdb'; +import { type Experience } from '../../AzureDBExperiences'; // Selecting only the properties used in the extension, but keeping an easy option to extend the model later and offer full coverage of MongoCluster // '|' means that you can only access properties that are common to both types. @@ -15,6 +16,10 @@ interface ResourceModelInUse extends Resource { name: string; administratorLoginPassword?: string; + + /** + * This connection string does not contain user credentials. + */ connectionString?: string; location?: string; @@ -32,4 +37,9 @@ interface ResourceModelInUse extends Resource { // introduced new properties resourceGroup?: string; + + // adding support for MongoRU and vCore + dbExperience?: Experience; + + isServerless?: boolean; } diff --git a/src/mongoClusters/tree/MongoClusterResourceItem.ts b/src/mongoClusters/tree/MongoClusterResourceItem.ts index eeb7c6b4b..b17b22d19 100644 --- a/src/mongoClusters/tree/MongoClusterResourceItem.ts +++ b/src/mongoClusters/tree/MongoClusterResourceItem.ts @@ -25,14 +25,47 @@ import { ProvideUserNameStep } from '../wizards/authenticate/ProvideUsernameStep import { MongoClusterItemBase } from './MongoClusterItemBase'; import { type MongoClusterModel } from './MongoClusterModel'; +import ConnectionString from 'mongodb-connection-string-url'; + export class MongoClusterResourceItem extends MongoClusterItemBase { constructor( - private readonly subscription: AzureSubscription, + readonly subscription: AzureSubscription, mongoCluster: MongoClusterModel, ) { super(mongoCluster); } + public async discoverConnectionString(): Promise { + return callWithTelemetryAndErrorHandling( + 'cosmosDB.mongoClusters.discoverConnectionString', + async (context: IActionContext) => { + // Create a client to interact with the MongoDB vCore management API and read the cluster details + const managementClient = await createMongoClustersManagementClient(context, this.subscription); + + const clusterInformation = await managementClient.mongoClusters.get( + this.mongoCluster.resourceGroup as string, + this.mongoCluster.name, + ); + + if (!clusterInformation.connectionString) { + return undefined; + } + + context.valuesToMask.push(clusterInformation.connectionString); + const connectionString = new ConnectionString(clusterInformation.connectionString as string); + + if (clusterInformation.administratorLogin) { + context.valuesToMask.push(clusterInformation.administratorLogin); + connectionString.username = clusterInformation.administratorLogin; + } + + connectionString.password = ''; + + return connectionString.toString(); + }, + ); + } + /** * Authenticates and connects to the MongoDB cluster. * @param context The action context. diff --git a/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts b/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts index 7a9810608..b6bf8e713 100644 --- a/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts +++ b/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { getResourceGroupFromId, uiUtils } from '@microsoft/vscode-azext-azureutils'; import { callWithTelemetryAndErrorHandling, nonNullProp, type IActionContext } from '@microsoft/vscode-azext-utils'; import { @@ -13,17 +12,16 @@ import { type ResourceModelBase, } from '@microsoft/vscode-azureresources-api'; import * as vscode from 'vscode'; -import { API } from '../../AzureDBExperiences'; +import { API, MongoClustersExprience } from '../../AzureDBExperiences'; import { ext } from '../../extensionVariables'; import { createMongoClustersManagementClient } from '../../utils/azureClients'; +import { type MongoClusterItemBase } from './MongoClusterItemBase'; import { type MongoClusterModel } from './MongoClusterModel'; import { MongoClusterResourceItem } from './MongoClusterResourceItem'; export interface TreeElementBase extends ResourceModelBase { getChildren?(): vscode.ProviderResult; getTreeItem(): vscode.TreeItem | Thenable; - - //viewProperties?: ViewPropertiesModel; } export class MongoClustersBranchDataProvider @@ -98,6 +96,7 @@ export class MongoClustersBranchDataProvider // 1. extract the basic info from the element (subscription, resource group, etc., provided by Azure Resources) let clusterInfo: MongoClusterModel = element as MongoClusterModel; + clusterInfo.dbExperience = MongoClustersExprience; // 2. lookup the details in the cache, on subsequent refreshes, the details will be available in the cache if (this.detailsCache.has(clusterInfo.id)) { @@ -117,7 +116,7 @@ export class MongoClustersBranchDataProvider ); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return ext.state.wrapItemInStateHandling(resourceItem!, () => this.refresh(resourceItem)); + return ext.state.wrapItemInStateHandling(resourceItem!, (item: MongoClusterItemBase) => this.refresh(item)); } async updateResourceCache( @@ -144,6 +143,7 @@ export class MongoClustersBranchDataProvider accounts.map((MongoClustersAccount) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument this.detailsCache.set(nonNullProp(MongoClustersAccount, 'id'), { + dbExperience: MongoClustersExprience, id: MongoClustersAccount.id as string, name: MongoClustersAccount.name as string, resourceGroup: getResourceGroupFromId(MongoClustersAccount.id as string), diff --git a/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts b/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts index b64f7844b..840f83b90 100644 --- a/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts +++ b/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts @@ -30,6 +30,11 @@ export class MongoClusterWorkspaceItem extends MongoClusterItemBase { super(mongoCluster); } + // eslint-disable-next-line @typescript-eslint/require-await + public async discoverConnectionString(): Promise { + return this.mongoCluster.connectionString; + } + /** * Authenticates and connects to the MongoDB cluster. * @param context The action context. @@ -153,7 +158,7 @@ export class MongoClusterWorkspaceItem extends MongoClusterItemBase { getTreeItem(): vscode.TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.mongoCluster', + contextValue: this.contextValue, label: this.mongoCluster.name, description: this.mongoCluster.sku !== undefined ? `(${this.mongoCluster.sku})` : false, iconPath: new vscode.ThemeIcon('server-environment'), // Uncomment if icon is available diff --git a/src/mongoClusters/tree/workspace/MongoDBAccountsWorkspaceItem.ts b/src/mongoClusters/tree/workspace/MongoDBAccountsWorkspaceItem.ts index e29c37760..5697d0620 100644 --- a/src/mongoClusters/tree/workspace/MongoDBAccountsWorkspaceItem.ts +++ b/src/mongoClusters/tree/workspace/MongoDBAccountsWorkspaceItem.ts @@ -3,18 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createGenericElement, type TreeElementBase } from '@microsoft/vscode-azext-utils'; +import { createGenericElement, type TreeElementBase, type TreeElementWithId } from '@microsoft/vscode-azext-utils'; import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from 'vscode'; -import { WorkspaceResourceType } from '../../../tree/workspace/sharedWorkspaceResourceProvider'; -import { SharedWorkspaceStorage } from '../../../tree/workspace/sharedWorkspaceStorage'; +import { MongoClustersExprience, type Experience } from '../../../AzureDBExperiences'; +import { type TreeElementWithExperience } from '../../../tree/TreeElementWithExperience'; +import { WorkspaceResourceType } from '../../../tree/workspace/SharedWorkspaceResourceProvider'; +import { SharedWorkspaceStorage } from '../../../tree/workspace/SharedWorkspaceStorage'; import { type MongoClusterModel } from '../MongoClusterModel'; import { MongoClusterWorkspaceItem } from './MongoClusterWorkspaceItem'; -export class MongoDBAccountsWorkspaceItem implements TreeElementBase { +export class MongoDBAccountsWorkspaceItem implements TreeElementWithId, TreeElementWithExperience { id: string; + experience?: Experience; constructor() { this.id = `vscode.cosmosdb.workspace.mongoclusters.accounts`; + this.experience = MongoClustersExprience; } async getChildren(): Promise { @@ -25,12 +29,13 @@ export class MongoDBAccountsWorkspaceItem implements TreeElementBase { const model: MongoClusterModel = { id: item.id, name: item.name, + dbExperience: MongoClustersExprience, connectionString: item?.secrets?.[0] ?? undefined, }; return new MongoClusterWorkspaceItem(model); }), createGenericElement({ - contextValue: this.id + '/newConnection', + contextValue: 'treeItem.newConnection', id: this.id + '/newConnection', label: 'New Connection...', iconPath: new ThemeIcon('plus'), diff --git a/src/mongoClusters/wizards/create/PromptDatabaseNameStep.ts b/src/mongoClusters/wizards/create/PromptDatabaseNameStep.ts index 2b04523ac..9fbd09a6a 100644 --- a/src/mongoClusters/wizards/create/PromptDatabaseNameStep.ts +++ b/src/mongoClusters/wizards/create/PromptDatabaseNameStep.ts @@ -82,7 +82,7 @@ export class DatabaseNameStep extends AzureWizardPromptStep(key: string, value: T, prefix: string = ext.prefix): Promise { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix); + await projectConfiguration.update(key, value, ConfigurationTarget.Global); + } + + /** + * Directly updates one of the user's `Workspace` or `WorkspaceFolder` settings. + * @param key The key of the setting to update + * @param value The value of the setting to update + * @param fsPath The path of the workspace configuration settings + * @param targetSetting The optional workspace setting to target. Uses the `Workspace` configuration target unless otherwise specified + * @param prefix The optional extension prefix. Uses ext.prefix unless otherwise specified + */ + async updateWorkspaceSetting( + key: string, + value: T, + fsPath: string, + targetSetting: + | ConfigurationTarget.Workspace + | ConfigurationTarget.WorkspaceFolder = ConfigurationTarget.Workspace, + prefix: string = ext.prefix, + ): Promise { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix, Uri.file(fsPath)); + await projectConfiguration.update(key, value, targetSetting); + } + + /** + * Directly retrieves one of the user's `Global` configuration settings. + * @param key The key of the setting to retrieve + * @param prefix The optional extension prefix. Uses ext.prefix unless otherwise specified + */ + getGlobalSetting(key: string, prefix: string = ext.prefix): T | undefined { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix); + const result: { globalValue?: T; defaultValue?: T } | undefined = projectConfiguration.inspect(key); + return result?.globalValue === undefined ? result?.defaultValue : result?.globalValue; + } + + /** + * Iteratively retrieves one of the user's workspace settings - sequentially checking for a defined value starting from the `WorkspaceFolder` up to the provided target configuration limit. + * @param key The key of the setting to retrieve + * @param fsPath The optional path of the workspace configuration settings + * @param targetLimit The optional target configuration limit (inclusive). Uses the `Workspace` configuration target unless otherwise specified + * @param prefix The optional extension prefix. Uses ext.prefix unless otherwise specified + */ + getWorkspaceSetting( + key: string, + fsPath?: string, + targetLimit: + | ConfigurationTarget.Workspace + | ConfigurationTarget.WorkspaceFolder = ConfigurationTarget.Workspace, + prefix: string = ext.prefix, + ): T | undefined { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration( + prefix, + fsPath ? Uri.file(fsPath) : undefined, + ); + + const configurationLevel: ConfigurationTarget | undefined = this.getLowestConfigurationLevel( + projectConfiguration, + key, + ); + if (!configurationLevel || (configurationLevel && configurationLevel < targetLimit)) { + return undefined; + } + + return projectConfiguration.get(key); + } + + /** + * Iteratively retrieves one of the user's settings - sequentially checking for a defined value starting from the `WorkspaceFolder` up to the `Global` configuration target. + * @param key The key of the setting to retrieve + * @param fsPath The optional path of the workspace configuration settings + * @param prefix The optional extension prefix. Uses ext.prefix unless otherwise specified + */ + getSetting(key: string, fsPath?: string, prefix: string = ext.prefix): T | undefined { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration( + prefix, + fsPath ? Uri.file(fsPath) : undefined, + ); + return projectConfiguration.get(key); + } + + /** + * Searches through all open folders and gets the current workspace setting (as long as there are no conflicts) + * Uses ext.prefix unless otherwise specified + */ + getWorkspaceSettingFromAnyFolder(key: string, prefix: string = ext.prefix): string | undefined { + if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { + let result: string | undefined; + for (const folder of workspace.workspaceFolders) { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix, folder.uri); + const folderResult: string | undefined = projectConfiguration.get(key); + if (!result) { + result = folderResult; + } else if (folderResult && result !== folderResult) { + return undefined; + } + } + return result; + } else { + return this.getGlobalSetting(key, prefix); + } + } + + getDefaultRootWorkspaceSettingsPath(rootWorkspaceFolder: WorkspaceFolder): string { + return path.join(rootWorkspaceFolder.uri.fsPath, vscodeFolder, settingsFile); + } + + getLowestConfigurationLevel( + projectConfiguration: WorkspaceConfiguration, + key: string, + ): ConfigurationTarget | undefined { + const configuration = projectConfiguration.inspect(key); + + let lowestLevelConfiguration: ConfigurationTarget | undefined; + if (configuration?.workspaceFolderValue !== undefined) { + lowestLevelConfiguration = ConfigurationTarget.WorkspaceFolder; + } else if (configuration?.workspaceValue !== undefined) { + lowestLevelConfiguration = ConfigurationTarget.Workspace; + } else if (configuration?.globalValue !== undefined) { + lowestLevelConfiguration = ConfigurationTarget.Global; + } + + return lowestLevelConfiguration; + } +} + +export const SettingsService = new SettingUtils(); diff --git a/src/table/tree/TableAccountTreeItem.ts b/src/table/tree/TableAccountTreeItem.ts index d387ffe41..9e66aa18d 100644 --- a/src/table/tree/TableAccountTreeItem.ts +++ b/src/table/tree/TableAccountTreeItem.ts @@ -11,7 +11,7 @@ import { } from '@microsoft/vscode-azext-utils'; import { API } from '../../AzureDBExperiences'; import { deleteCosmosDBAccount } from '../../commands/deleteDatabaseAccount/deleteCosmosDBAccount'; -import { type IDeleteWizardContext } from '../../commands/deleteDatabaseAccount/IDeleteWizardContext'; +import { type DeleteWizardContext } from '../../commands/deleteDatabaseAccount/DeleteWizardContext'; import { DocDBAccountTreeItemBase } from '../../docdb/tree/DocDBAccountTreeItemBase'; export class TableAccountTreeItem extends DocDBAccountTreeItemBase { @@ -45,7 +45,7 @@ export class TableAccountTreeItem extends DocDBAccountTreeItemBase { return result ?? []; } - public async deleteTreeItemImpl(context: IDeleteWizardContext): Promise { + public async deleteTreeItemImpl(context: DeleteWizardContext): Promise { await deleteCosmosDBAccount(context, this); } diff --git a/src/tree/AttachedAccountsTreeItem.ts b/src/tree/AttachedAccountsTreeItem.ts index e815f397b..28c11a66e 100644 --- a/src/tree/AttachedAccountsTreeItem.ts +++ b/src/tree/AttachedAccountsTreeItem.ts @@ -34,7 +34,7 @@ import { localize } from '../utils/localize'; import { nonNullProp, nonNullValue } from '../utils/nonNull'; import { SubscriptionTreeItem } from './SubscriptionTreeItem'; -interface IPersistedAccount { +export interface IPersistedAccount { id: string; // defaultExperience is not the same as API but we can't change the name due to backwards compatibility defaultExperience: API; @@ -49,7 +49,7 @@ const localMongoConnectionString: string = 'mongodb://127.0.0.1:27017'; export class AttachedAccountsTreeItem extends AzExtParentTreeItem { public static contextValue: string = 'cosmosDBAttachedAccounts' + (isWindows ? 'WithEmulator' : 'WithoutEmulator'); public readonly contextValue: string = AttachedAccountsTreeItem.contextValue; - public readonly label: string = 'Attached Database Accounts'; + public readonly label: string = 'Attached Database Accounts (Postgres)'; public childTypeLabel: string = 'Account'; public suppressMaskLabel = true; @@ -359,7 +359,10 @@ export class AttachedAccountsTreeItem extends AzExtParentTreeItem { await ext.secretStorage.get(getSecretStorageKey(this._serviceName, id)), 'connectionString', ); - persistedAccounts.push(await this.createTreeItem(connectionString, api, label, id, isEmulator)); + // TODO: Left only Postgres, other types are moved to new tree api v2 + if (api === API.PostgresSingle || api === API.PostgresFlexible) { + persistedAccounts.push(await this.createTreeItem(connectionString, api, label, id, isEmulator)); + } }), ); } diff --git a/src/tree/AzureAccountTreeItemWithAttached.ts b/src/tree/AzureAccountTreeItemWithAttached.ts deleted file mode 100644 index 226a1a43b..000000000 --- a/src/tree/AzureAccountTreeItemWithAttached.ts +++ /dev/null @@ -1,36 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { AzureAccountTreeItemBase } from '@microsoft/vscode-azext-azureutils'; -import { type AzExtTreeItem, type IActionContext, type ISubscriptionContext } from '@microsoft/vscode-azext-utils'; -import { ext } from '../extensionVariables'; -import { AttachedAccountsTreeItem } from './AttachedAccountsTreeItem'; -import { SubscriptionTreeItem } from './SubscriptionTreeItem'; - -export class AzureAccountTreeItemWithAttached extends AzureAccountTreeItemBase { - public constructor(testAccount?: object) { - super(undefined, testAccount); - ext.attachedAccountsNode = new AttachedAccountsTreeItem(this); - } - - public createSubscriptionTreeItem(root: ISubscriptionContext): SubscriptionTreeItem { - return new SubscriptionTreeItem(this, root); - } - - public async loadMoreChildrenImpl(clearCache: boolean, context: IActionContext): Promise { - const children: AzExtTreeItem[] = await super.loadMoreChildrenImpl(clearCache, context); - return children.concat(ext.attachedAccountsNode); - } - - public compareChildrenImpl(item1: AzExtTreeItem, item2: AzExtTreeItem): number { - if (item1 instanceof AttachedAccountsTreeItem) { - return 1; - } else if (item2 instanceof AttachedAccountsTreeItem) { - return -1; - } else { - return super.compareChildrenImpl(item1, item2); - } - } -} diff --git a/src/tree/CosmosAccountModel.ts b/src/tree/CosmosAccountModel.ts new file mode 100644 index 000000000..b85ed46fc --- /dev/null +++ b/src/tree/CosmosAccountModel.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type GenericResource } from '@azure/arm-resources'; +import { type AzureResource, type WorkspaceResource } from '@microsoft/vscode-azureresources-api'; + +export type CosmosDBWorkspaceResource = WorkspaceResource; + +export interface CosmosDBWorkspaceModel extends CosmosDBWorkspaceResource { + connectionString?: string; +} + +/** + * Cosmos DB resource + * Azure Resource group library mixes the raw generic resource into AzureResource + * Therefore, we can access the raw generic resource from the CosmosDBResource + * However, ideally we have to use raw property to access to the Cosmos DB resource + */ +export type CosmosDBResource = AzureResource & + GenericResource & { + readonly raw: GenericResource; // Resource object from Azure SDK + }; + +export type CosmosAccountModel = CosmosDBResource; diff --git a/src/tree/CosmosAccountResourceItemBase.ts b/src/tree/CosmosAccountResourceItemBase.ts new file mode 100644 index 000000000..e3975ccfa --- /dev/null +++ b/src/tree/CosmosAccountResourceItemBase.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import { type ResourceBase } from '@microsoft/vscode-azureresources-api'; +import { v4 as uuid } from 'uuid'; +import * as vscode from 'vscode'; +import { type TreeItem } from 'vscode'; +import { type Experience } from '../AzureDBExperiences'; +import { type CosmosDBTreeElement } from './CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from './TreeElementWithContextValue'; +import { type TreeElementWithExperience } from './TreeElementWithExperience'; + +export abstract class CosmosAccountResourceItemBase + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.account'; + + protected constructor( + public readonly account: ResourceBase, + public readonly experience: Experience, + ) { + this.id = account.id ?? uuid(); + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + /** + * Returns the children of the cluster. + * @returns The children of the cluster. + */ + getChildren(): Promise { + return Promise.resolve([]); + } + + /** + * Returns the tree item representation of the cluster. + * @returns The TreeItem object. + */ + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: this.account.name, + description: `(${this.experience.shortName})`, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } +} diff --git a/src/tree/CosmosDBBranchDataProvider.ts b/src/tree/CosmosDBBranchDataProvider.ts new file mode 100644 index 000000000..ca1be4315 --- /dev/null +++ b/src/tree/CosmosDBBranchDataProvider.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + createGenericElement, + parseError, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import { type BranchDataProvider } from '@microsoft/vscode-azureresources-api'; +import * as vscode from 'vscode'; +import { API, CoreExperience, tryGetExperience } from '../AzureDBExperiences'; +import { databaseAccountType } from '../constants'; +import { ext } from '../extensionVariables'; +import { localize } from '../utils/localize'; +import { nonNullProp } from '../utils/nonNull'; +import { type CosmosAccountModel, type CosmosDBResource } from './CosmosAccountModel'; +import { type CosmosDBTreeElement } from './CosmosDBTreeElement'; +import { GraphAccountResourceItem } from './graph/GraphAccountResourceItem'; +import { MongoAccountResourceItem } from './mongo/MongoAccountResourceItem'; +import { NoSqlAccountResourceItem } from './nosql/NoSqlAccountResourceItem'; +import { TableAccountResourceItem } from './table/TableAccountResourceItem'; +import { isTreeElementWithContextValue } from './TreeElementWithContextValue'; +import { isTreeElementWithExperience } from './TreeElementWithExperience'; + +export class CosmosDBBranchDataProvider + extends vscode.Disposable + implements BranchDataProvider +{ + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + + constructor() { + super(() => this.onDidChangeTreeDataEmitter.dispose()); + } + + get onDidChangeTreeData(): vscode.Event { + return this.onDidChangeTreeDataEmitter.event; + } + + /** + * This function is called for every element in the tree when expanding, the element being expanded is being passed as an argument + */ + async getChildren(element: CosmosDBTreeElement): Promise { + try { + const result = await callWithTelemetryAndErrorHandling( + 'CosmosDBBranchDataProvider.getChildren', + async (context: IActionContext) => { + context.errorHandling.suppressDisplay = true; + context.errorHandling.rethrow = true; + context.errorHandling.forceIncludeInReportIssueCommand = true; + + if (isTreeElementWithContextValue(element)) { + context.telemetry.properties.parentContext = element.contextValue; + } + + if (isTreeElementWithExperience(element)) { + context.telemetry.properties.experience = element.experience?.api ?? API.Common; + } + + // TODO: values to mask. New TreeElements do not have valueToMask field + // I assume this array should be filled after element.getChildren() call + // And these values should be masked in the context + + const children = (await element.getChildren?.()) ?? []; + return children.map((child) => { + return ext.state.wrapItemInStateHandling(child, (child: CosmosDBTreeElement) => + this.refresh(child), + ) as CosmosDBTreeElement; + }); + }, + ); + + return result ?? []; + } catch (error) { + return [ + createGenericElement({ + contextValue: 'cosmosDB.item.error', + label: localize('Error: {0}', parseError(error).message), + }) as CosmosDBTreeElement, + ]; + } + } + + /** + * This function is being called when the resource tree is being built, it is called for every top level of resources. + * @param resource + */ + async getResourceItem(resource: CosmosDBResource): Promise { + const resourceItem = await callWithTelemetryAndErrorHandling( + 'CosmosDBBranchDataProvider.getResourceItem', + (context: IActionContext) => { + const id = nonNullProp(resource, 'id'); + const name = nonNullProp(resource, 'name'); + const type = nonNullProp(resource, 'type'); + + context.valuesToMask.push(id); + context.valuesToMask.push(name); + + if (type.toLocaleLowerCase() === databaseAccountType.toLocaleLowerCase()) { + const accountModel = resource as CosmosAccountModel; + const experience = tryGetExperience(resource); + + if (experience?.api === API.MongoDB) { + return new MongoAccountResourceItem(accountModel, experience); + } + + if (experience?.api === API.Cassandra) { + return new NoSqlAccountResourceItem(accountModel, experience); + } + + if (experience?.api === API.Core) { + return new NoSqlAccountResourceItem(accountModel, experience); + } + + if (experience?.api === API.Graph) { + return new GraphAccountResourceItem(accountModel, experience); + } + + if (experience?.api === API.Table) { + return new TableAccountResourceItem(accountModel, experience); + } + + // Unknown experience fallback + return new NoSqlAccountResourceItem(accountModel, CoreExperience); + } else { + // Unknown resource type + } + + return null as unknown as CosmosDBTreeElement; + }, + ); + + if (resourceItem) { + return ext.state.wrapItemInStateHandling(resourceItem, (item: CosmosDBTreeElement) => + this.refresh(item), + ) as CosmosDBTreeElement; + } + + return null as unknown as CosmosDBTreeElement; + } + + async getTreeItem(element: CosmosDBTreeElement): Promise { + return element.getTreeItem(); + } + + refresh(element?: CosmosDBTreeElement): void { + this.onDidChangeTreeDataEmitter.fire(element); + } +} diff --git a/src/tree/CosmosDBTreeElement.ts b/src/tree/CosmosDBTreeElement.ts new file mode 100644 index 000000000..be2ddd16c --- /dev/null +++ b/src/tree/CosmosDBTreeElement.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type TreeElementWithId } from '@microsoft/vscode-azext-utils'; +import type * as vscode from 'vscode'; + +export interface ExtTreeElementBase extends TreeElementWithId { + getChildren?(): vscode.ProviderResult; + getTreeItem(): vscode.TreeItem | Thenable; +} + +export type CosmosDBTreeElement = ExtTreeElementBase; diff --git a/src/tree/CosmosDBWorkspaceBranchDataProvider.ts b/src/tree/CosmosDBWorkspaceBranchDataProvider.ts new file mode 100644 index 000000000..6202cd22d --- /dev/null +++ b/src/tree/CosmosDBWorkspaceBranchDataProvider.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + createGenericElement, + type IActionContext, + parseError, +} from '@microsoft/vscode-azext-utils'; +import { type BranchDataProvider } from '@microsoft/vscode-azureresources-api'; +import * as vscode from 'vscode'; +import { API } from '../AzureDBExperiences'; +import { ext } from '../extensionVariables'; +import { localize } from '../utils/localize'; +import { CosmosDBAttachedAccountsResourceItem } from './attached/CosmosDBAttachedAccountsResourceItem'; +import { type CosmosDBResource } from './CosmosAccountModel'; +import { type CosmosDBTreeElement } from './CosmosDBTreeElement'; +import { isTreeElementWithContextValue } from './TreeElementWithContextValue'; +import { isTreeElementWithExperience } from './TreeElementWithExperience'; + +export class CosmosDBWorkspaceBranchDataProvider + extends vscode.Disposable + implements BranchDataProvider +{ + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + + constructor() { + super(() => this.onDidChangeTreeDataEmitter.dispose()); + } + + get onDidChangeTreeData(): vscode.Event { + return this.onDidChangeTreeDataEmitter.event; + } + + /** + * This function is called for every element in the tree when expanding, the element being expanded is being passed as an argument + */ + async getChildren(element: CosmosDBTreeElement): Promise { + try { + const result = await callWithTelemetryAndErrorHandling( + 'CosmosDBWorkspaceBranchDataProvider.getChildren', + async (context: IActionContext) => { + context.telemetry.properties.view = 'workspace'; + context.errorHandling.suppressDisplay = true; + context.errorHandling.rethrow = true; + context.errorHandling.forceIncludeInReportIssueCommand = true; + + if (isTreeElementWithContextValue(element)) { + context.telemetry.properties.parentContext = element.contextValue; + } + + if (isTreeElementWithExperience(element)) { + context.telemetry.properties.experience = element.experience?.api ?? API.Common; + } + + // TODO: values to mask. New TreeElements do not have valueToMask field + // I assume this array should be filled after element.getChildren() call + // And these values should be masked in the context + + const children = (await element.getChildren?.()) ?? []; + return children.map((child) => { + return ext.state.wrapItemInStateHandling(child, (child: CosmosDBTreeElement) => + this.refresh(child), + ) as CosmosDBTreeElement; + }); + }, + ); + + return result ?? []; + } catch (error) { + return [ + createGenericElement({ + contextValue: 'cosmosDB.workspace.item.error', + label: localize('Error: {0}', parseError(error).message), + }) as CosmosDBTreeElement, + ]; + } + } + + /** + * This function is being called when the resource tree is being built, it is called for every top level of resources. + */ + async getResourceItem(): Promise { + const resourceItem = await callWithTelemetryAndErrorHandling( + 'CosmosDBWorkspaceBranchDataProvider.getResourceItem', + () => new CosmosDBAttachedAccountsResourceItem(), + ); + + if (resourceItem) { + return ext.state.wrapItemInStateHandling(resourceItem, (item: CosmosDBTreeElement) => + this.refresh(item), + ) as CosmosDBTreeElement; + } + + return null as unknown as CosmosDBTreeElement; + } + + async getTreeItem(element: CosmosDBTreeElement): Promise { + return element.getTreeItem(); + } + + refresh(element?: CosmosDBTreeElement): void { + this.onDidChangeTreeDataEmitter.fire(element); + } +} diff --git a/src/tree/TreeElementWithContextValue.ts b/src/tree/TreeElementWithContextValue.ts new file mode 100644 index 000000000..a7bed5d25 --- /dev/null +++ b/src/tree/TreeElementWithContextValue.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type TreeElementWithContextValue = { + readonly contextValue: string; +}; + +export function isTreeElementWithContextValue(node: unknown): node is TreeElementWithContextValue { + return typeof node === 'object' && node !== null && 'contextValue' in node; +} diff --git a/src/tree/TreeElementWithExperience.ts b/src/tree/TreeElementWithExperience.ts new file mode 100644 index 000000000..3f04962f4 --- /dev/null +++ b/src/tree/TreeElementWithExperience.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../AzureDBExperiences'; + +/** + * It's currently being kept separately from the CosmosDbTreeElement as we need to discuss it with the team, + * as we're working on an overlapping feature in parallel, keeping the 'experience' property in a separate + * interface simplifies parallel development and can still be easily merged once ready for it. + */ +export type TreeElementWithExperience = { + experience?: Experience; // optional during the migration phase +}; + +/** + * Type guard function to check if a given node is a `TreeElementWithExperience`. + * + * @param node - The node to check. + * @returns `true` if the node is an object and has an `experience` property, otherwise `false`. + */ +export function isTreeElementWithExperience(node: unknown): node is TreeElementWithExperience { + return typeof node === 'object' && node !== null && 'experience' in node; +} diff --git a/src/tree/attached/CosmosDBAttachedAccountModel.ts b/src/tree/attached/CosmosDBAttachedAccountModel.ts new file mode 100644 index 000000000..f1574b7c0 --- /dev/null +++ b/src/tree/attached/CosmosDBAttachedAccountModel.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type CosmosDBAttachedAccountModel = { + connectionString: string; + id: string; + isEmulator: boolean; + name: string; +}; diff --git a/src/tree/attached/CosmosDBAttachedAccountsResourceItem.ts b/src/tree/attached/CosmosDBAttachedAccountsResourceItem.ts new file mode 100644 index 000000000..c981f0c08 --- /dev/null +++ b/src/tree/attached/CosmosDBAttachedAccountsResourceItem.ts @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + createContextValue, + createGenericElement, + nonNullValue, +} from '@microsoft/vscode-azext-utils'; +import vscode, { ThemeIcon, TreeItemCollapsibleState } from 'vscode'; +import { API, getExperienceFromApi } from '../../AzureDBExperiences'; +import { isWindows } from '../../constants'; +import { ext } from '../../extensionVariables'; +import { type IPersistedAccount } from '../AttachedAccountsTreeItem'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { GraphAccountAttachedResourceItem } from '../graph/GraphAccountAttachedResourceItem'; +import { NoSqlAccountAttachedResourceItem } from '../nosql/NoSqlAccountAttachedResourceItem'; +import { TableAccountAttachedResourceItem } from '../table/TableAccountAttachedResourceItem'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { WorkspaceResourceType } from '../workspace/SharedWorkspaceResourceProvider'; +import { SharedWorkspaceStorage, type SharedWorkspaceStorageItem } from '../workspace/SharedWorkspaceStorage'; +import { type CosmosDBAttachedAccountModel } from './CosmosDBAttachedAccountModel'; + +export class CosmosDBAttachedAccountsResourceItem implements CosmosDBTreeElement, TreeElementWithContextValue { + public readonly id: string = WorkspaceResourceType.AttachedAccounts; + public readonly contextValue: string = 'treeItem.accounts'; + + private readonly attachDatabaseAccount: CosmosDBTreeElement; + private readonly attachEmulator: CosmosDBTreeElement; + + constructor() { + this.id = WorkspaceResourceType.AttachedAccounts; + this.contextValue = createContextValue([this.contextValue, `attachedAccounts`]); + + this.attachDatabaseAccount = createGenericElement({ + id: `${this.id}/attachAccount`, + contextValue: `${this.contextValue}/attachAccount`, + label: 'Attach Database Account\u2026', + iconPath: new vscode.ThemeIcon('plus'), + commandId: 'cosmosDB.attachDatabaseAccount', + includeInTreeItemPicker: true, + }) as CosmosDBTreeElement; + + this.attachEmulator = createGenericElement({ + id: `${this.id}/attachEmulator`, + contextValue: `${this.contextValue}/attachEmulator`, + label: 'Attach Emulator\u2026', + iconPath: new vscode.ThemeIcon('plus'), + commandId: 'cosmosDB.attachEmulator', + includeInTreeItemPicker: true, + }) as CosmosDBTreeElement; + } + + public async getChildren(): Promise { + // TODO: remove after a few releases + await this.pickSupportedAccounts(); // Move accounts from the old storage format to the new one + + const items = await SharedWorkspaceStorage.getItems(this.id); + const children = await this.getChildrenImpl(items); + const auxItems = isWindows ? [this.attachDatabaseAccount, this.attachEmulator] : [this.attachDatabaseAccount]; + + return [...children, ...auxItems]; + } + + public getTreeItem() { + return { + id: this.id, + contextValue: this.contextValue, + label: 'Attached Database Accounts', + iconPath: new ThemeIcon('plug'), + collapsibleState: TreeItemCollapsibleState.Collapsed, + }; + } + + protected async getChildrenImpl(items: SharedWorkspaceStorageItem[]): Promise { + return Promise.resolve( + items + .map((item) => { + const { id, name, properties, secrets } = item; + const api: API = nonNullValue(properties?.api, 'api') as API; + const isEmulator: boolean = !!nonNullValue(properties?.isEmulator, 'isEmulator'); + const connectionString: string = nonNullValue(secrets?.[0], 'connectionString'); + const experience = getExperienceFromApi(api); + const accountModel: CosmosDBAttachedAccountModel = { + id, + name, + connectionString, + isEmulator, + }; + + if (experience?.api === API.Cassandra) { + return new NoSqlAccountAttachedResourceItem(accountModel, experience); + } + + if (experience?.api === API.Core) { + return new NoSqlAccountAttachedResourceItem(accountModel, experience); + } + + if (experience?.api === API.Graph) { + return new GraphAccountAttachedResourceItem(accountModel, experience); + } + + if (experience?.api === API.Table) { + return new TableAccountAttachedResourceItem(accountModel, experience); + } + + // Unknown experience + return undefined; + }) + .filter((r) => r !== undefined), + ); + } + + protected async pickSupportedAccounts(): Promise { + return callWithTelemetryAndErrorHandling( + 'CosmosDBAttachedAccountsResourceItem.pickSupportedAccounts', + async () => { + const serviceName = 'ms-azuretools.vscode-cosmosdb.connectionStrings'; + const value: string | undefined = ext.context.globalState.get(serviceName); + + if (!value) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const accounts: (string | IPersistedAccount)[] = JSON.parse(value); + for (const account of accounts) { + let id: string; + let name: string; + let isEmulator: boolean; + let api: API; + + if (typeof account === 'string') { + // Default to Mongo if the value is a string for the sake of backwards compatibility + // (Mongo was originally the only account type that could be attached) + id = account; + name = account; + api = API.MongoDB; + isEmulator = false; + } else { + id = (account).id; + name = (account).id; + api = (account).defaultExperience; + isEmulator = (account).isEmulator ?? false; + } + + // TODO: Ignore Postgres accounts until we have a way to handle them + if (api === API.PostgresSingle || api === API.PostgresFlexible) { + continue; + } + + const connectionString: string = nonNullValue( + await ext.secretStorage.get(`${serviceName}.${id}`), + 'connectionString', + ); + + const storageItem: SharedWorkspaceStorageItem = { + id, + name, + properties: { isEmulator, api }, + secrets: [connectionString], + }; + + await SharedWorkspaceStorage.push(WorkspaceResourceType.AttachedAccounts, storageItem, true); + } + }, + ); + } + + protected async migrateV1AccountsToV2(): Promise { + const serviceName = 'ms-azuretools.vscode-cosmosdb.connectionStrings'; + const value: string | undefined = ext.context.globalState.get(serviceName); + + if (!value) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const accounts: (string | IPersistedAccount)[] = JSON.parse(value); + const result = await Promise.allSettled( + accounts.map(async (account) => { + return callWithTelemetryAndErrorHandling( + 'CosmosDBAttachedAccountsResourceItem.migrateV1AccountsToV2', + async (context) => { + context.errorHandling.rethrow = true; + context.errorHandling.forceIncludeInReportIssueCommand = true; + + let id: string; + let name: string; + let isEmulator: boolean; + let api: API; + + if (typeof account === 'string') { + // Default to Mongo if the value is a string for the sake of backwards compatibility + // (Mongo was originally the only account type that could be attached) + id = account; + name = account; + api = API.MongoDB; + isEmulator = false; + } else { + id = (account).id; + name = (account).id; + api = (account).defaultExperience; + isEmulator = (account).isEmulator ?? false; + } + + const connectionString: string = nonNullValue( + await ext.secretStorage.get(`${serviceName}.${id}`), + 'connectionString', + ); + + const storageItem: SharedWorkspaceStorageItem = { + id, + name, + properties: { + isEmulator, + api, + }, + secrets: [connectionString], + }; + + await SharedWorkspaceStorage.push(WorkspaceResourceType.AttachedAccounts, storageItem); + await ext.secretStorage.delete(`${serviceName}.${id}`); + + return storageItem; + }, + ); + }), + ); + + const notMovedAccounts = result + .map((r, index) => { + if (r.status === 'rejected') { + // Couldn't migrate the account, won't remove it from the old list + return accounts[index]; + } + + const storageItem = r.value; + + if (storageItem?.properties?.api === API.MongoDB) { + // TODO: Tomasz will handle this + return accounts[index]; + } + + if ( + storageItem?.properties?.api === API.PostgresSingle || + storageItem?.properties?.api === API.PostgresFlexible + ) { + // TODO: Need to handle Postgres + return accounts[index]; + } + + return undefined; + }) + .filter((r) => r !== undefined); + + if (notMovedAccounts.length > 0) { + await ext.context.globalState.update(serviceName, JSON.stringify(notMovedAccounts)); + } else { + await ext.context.globalState.update(serviceName, undefined); + } + } +} diff --git a/src/tree/docdb/AccountInfo.ts b/src/tree/docdb/AccountInfo.ts new file mode 100644 index 000000000..ba8691ea0 --- /dev/null +++ b/src/tree/docdb/AccountInfo.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosDBCredential } from '../../docdb/getCosmosClient'; + +export interface AccountInfo { + credentials: CosmosDBCredential[]; + endpoint: string; + id: string; + isEmulator: boolean; + name: string; +} diff --git a/src/tree/docdb/DocumentDBAccountAttachedResourceItem.ts b/src/tree/docdb/DocumentDBAccountAttachedResourceItem.ts new file mode 100644 index 000000000..93691ae1f --- /dev/null +++ b/src/tree/docdb/DocumentDBAccountAttachedResourceItem.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosClient, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getThemeAgnosticIconPath } from '../../constants'; +import { parseDocDBConnectionString } from '../../docdb/docDBConnectionStrings'; +import { type CosmosDBCredential, type CosmosDBKeyCredential, getCosmosClient } from '../../docdb/getCosmosClient'; +import { getSignedInPrincipalIdForAccountEndpoint } from '../../docdb/utils/azureSessionHelper'; +import { isRbacException, showRbacPermissionError } from '../../docdb/utils/rbacUtils'; +import { localize } from '../../utils/localize'; +import { type CosmosDBAttachedAccountModel } from '../attached/CosmosDBAttachedAccountModel'; +import { CosmosAccountResourceItemBase } from '../CosmosAccountResourceItemBase'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type AccountInfo } from './AccountInfo'; + +export abstract class DocumentDBAccountAttachedResourceItem extends CosmosAccountResourceItemBase { + public declare readonly account: CosmosDBAttachedAccountModel; + + // To prevent the RBAC notification from showing up multiple times + protected hasShownRbacNotification: boolean = false; + + protected constructor(account: CosmosDBAttachedAccountModel, experience: Experience) { + super(account, experience); + } + + public async getChildren(): Promise { + const accountInfo = await this.getAccountInfo(this.account); + const cosmosClient = getCosmosClient(accountInfo.endpoint, accountInfo.credentials, false); + const databases = await this.getDatabases(accountInfo, cosmosClient); + + return this.getChildrenImpl(accountInfo, databases); + } + + public getTreeItem(): TreeItem { + return { ...super.getTreeItem(), iconPath: getThemeAgnosticIconPath('CosmosDBAccount.svg') }; + } + + protected async getAccountInfo(account: CosmosDBAttachedAccountModel): Promise | never { + const id = account.id; + const name = account.name; + const isEmulator = account.isEmulator; + const parsedCS = parseDocDBConnectionString(account.connectionString); + const documentEndpoint = parsedCS.documentEndpoint; + const credentials = await this.getCredentials(account); + + return { + credentials, + endpoint: documentEndpoint, + id, + isEmulator, + name, + }; + } + + protected async getDatabases( + accountInfo: AccountInfo, + cosmosClient: CosmosClient, + ): Promise<(DatabaseDefinition & Resource)[]> | never { + const getResources = async () => { + const result = await cosmosClient.databases.readAll().fetchAll(); + return result.resources; + }; + + try { + // Await is required here to ensure that the error is caught in the catch block + return await getResources(); + } catch (e) { + if (e instanceof Error && isRbacException(e) && !this.hasShownRbacNotification) { + this.hasShownRbacNotification = true; + + const principalId = (await getSignedInPrincipalIdForAccountEndpoint(accountInfo.endpoint)) ?? ''; + void showRbacPermissionError(this.id, principalId); + } + throw e; // rethrowing tells the resources extension to show the exception message in the tree + } + } + + protected async getCredentials(account: CosmosDBAttachedAccountModel): Promise { + const result = await callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { + context.telemetry.properties.experience = this.experience.api; + context.telemetry.properties.parentContext = this.contextValue; + + const forceOAuth = vscode.workspace.getConfiguration().get('azureDatabases.useCosmosOAuth'); + context.telemetry.properties.useCosmosOAuth = (forceOAuth ?? false).toString(); + + let keyCred: CosmosDBKeyCredential | undefined = undefined; + // disable key auth if the user has opted in to OAuth (AAD/Entra ID) + if (!forceOAuth) { + let localAuthDisabled = false; + + const parsedCS = parseDocDBConnectionString(account.connectionString); + if (parsedCS.masterKey) { + context.telemetry.properties.receivedKeyCreds = 'true'; + + keyCred = { + type: 'key', + key: parsedCS.masterKey, + }; + + try { + // Since here we don't have subscription, + // we can't get DatabaseAccountGetResults to retrieve disableLocalAuth property + // Will try to connect to the account and if it fails, we will assume local auth is disabled + const cosmosClient = getCosmosClient(parsedCS.documentEndpoint, [keyCred], account.isEmulator); + await cosmosClient.getDatabaseAccount(); + } catch { + context.telemetry.properties.receivedKeyCreds = 'false'; + localAuthDisabled = true; + } + } + + context.telemetry.properties.localAuthDisabled = localAuthDisabled.toString(); + if (localAuthDisabled) { + // Clean up keyCred if local auth is disabled + keyCred = undefined; + + const message = localize( + 'keyPermissionErrorMsg', + 'You do not have the required permissions to list auth keys for [{0}].\nFalling back to using Entra ID.\nYou can change the default authentication in the settings.', + account.name, + ); + const openSettingsItem = localize('openSettings', 'Open Settings'); + void vscode.window.showWarningMessage(message, ...[openSettingsItem]).then((item) => { + if (item === openSettingsItem) { + void vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'azureDatabases.useCosmosOAuth', + ); + } + }); + } + } + + // OAuth is always enabled for Cosmos DB and will be used as a fallback if key auth is unavailable + const authCred = { type: 'auth' }; + return [keyCred, authCred].filter((cred): cred is CosmosDBCredential => cred !== undefined); + }); + + return result ?? []; + } + + protected abstract getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise; +} diff --git a/src/tree/docdb/DocumentDBAccountResourceItem.ts b/src/tree/docdb/DocumentDBAccountResourceItem.ts new file mode 100644 index 000000000..7950de41b --- /dev/null +++ b/src/tree/docdb/DocumentDBAccountResourceItem.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosDBManagementClient, type DatabaseAccountGetResults } from '@azure/arm-cosmosdb'; +import { type DatabaseAccountListKeysResult } from '@azure/arm-cosmosdb/src/models'; +import { type CosmosClient, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getThemeAgnosticIconPath } from '../../constants'; +import { type CosmosDBCredential, type CosmosDBKeyCredential, getCosmosClient } from '../../docdb/getCosmosClient'; +import { getSignedInPrincipalIdForAccountEndpoint } from '../../docdb/utils/azureSessionHelper'; +import { ensureRbacPermissionV2, isRbacException, showRbacPermissionError } from '../../docdb/utils/rbacUtils'; +import { createCosmosDBManagementClient } from '../../utils/azureClients'; +import { localize } from '../../utils/localize'; +import { nonNullProp } from '../../utils/nonNull'; +import { type CosmosAccountModel } from '../CosmosAccountModel'; +import { CosmosAccountResourceItemBase } from '../CosmosAccountResourceItemBase'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type AccountInfo } from './AccountInfo'; + +export abstract class DocumentDBAccountResourceItem extends CosmosAccountResourceItemBase { + public declare readonly account: CosmosAccountModel; + + // To prevent the RBAC notification from showing up multiple times + protected hasShownRbacNotification: boolean = false; + + protected constructor(account: CosmosAccountModel, experience: Experience) { + super(account, experience); + } + + public async getChildren(): Promise { + const accountInfo = await this.getAccountInfo(this.account); + const cosmosClient = getCosmosClient(accountInfo.endpoint, accountInfo.credentials, false); + const databases = await this.getDatabases(accountInfo, cosmosClient); + + return this.getChildrenImpl(accountInfo, databases); + } + + public getTreeItem(): TreeItem { + return { ...super.getTreeItem(), iconPath: getThemeAgnosticIconPath('CosmosDBAccount.svg') }; + } + + public async getConnectionString(): Promise { + const accountInfo = await this.getAccountInfo(this.account); + + // supporting only one known success path + if ( + accountInfo.credentials.length === 2 && + accountInfo.credentials[0].type === 'key' && + accountInfo.credentials[1].type === 'auth' + ) { + return `AccountEndpoint=${accountInfo.endpoint};AccountKey=${accountInfo.credentials[0].key}`; + } else { + return undefined; + } + } + + protected async getAccountInfo(account: CosmosAccountModel): Promise | never { + const id = nonNullProp(account, 'id'); + const name = nonNullProp(account, 'name'); + const resourceGroup = nonNullProp(account, 'resourceGroup'); + + const client = await callWithTelemetryAndErrorHandling('getAccountInfo', async (context: IActionContext) => { + return createCosmosDBManagementClient(context, account.subscription); + }); + + if (!client) { + throw new Error('Failed to connect to Cosmos DB account'); + } + + const databaseAccount = await client.databaseAccounts.get(resourceGroup, name); + const credentials = await this.getCredentials(name, resourceGroup, client, databaseAccount); + const documentEndpoint = nonNullProp(databaseAccount, 'documentEndpoint', `of the database account ${id}`); + + return { + credentials, + endpoint: documentEndpoint, + id, + isEmulator: false, + name, + }; + } + + protected async getDatabases( + accountInfo: AccountInfo, + cosmosClient: CosmosClient, + ): Promise<(DatabaseDefinition & Resource)[]> | never { + const getResources = async () => { + const result = await cosmosClient.databases.readAll().fetchAll(); + return result.resources; + }; + + try { + // Await is required here to ensure that the error is caught in the catch block + return await getResources(); + } catch (e) { + if (e instanceof Error && isRbacException(e) && !this.hasShownRbacNotification) { + this.hasShownRbacNotification = true; + + const principalId = (await getSignedInPrincipalIdForAccountEndpoint(accountInfo.endpoint)) ?? ''; + // check if the principal ID matches the one that is signed in, + // otherwise this might be a security problem, hence show the error message + if ( + e.message.includes(`[${principalId}]`) && + (await ensureRbacPermissionV2(this.id, this.account.subscription, principalId)) + ) { + return getResources(); + } else { + void showRbacPermissionError(this.id, principalId); + } + } + throw e; // rethrowing tells the resources extension to show the exception message in the tree + } + } + + protected async getCredentials( + name: string, + resourceGroup: string, + client: CosmosDBManagementClient, + databaseAccount: DatabaseAccountGetResults, + ): Promise { + const result = await callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { + context.telemetry.properties.experience = this.experience.api; + context.telemetry.properties.parentContext = this.contextValue; + + const forceOAuth = vscode.workspace.getConfiguration().get('azureDatabases.useCosmosOAuth'); + context.telemetry.properties.useCosmosOAuth = (forceOAuth ?? false).toString(); + + let keyCred: CosmosDBKeyCredential | undefined = undefined; + // disable key auth if the user has opted in to OAuth (AAD/Entra ID) + if (!forceOAuth) { + try { + const localAuthDisabled = databaseAccount.disableLocalAuth === true; + context.telemetry.properties.localAuthDisabled = localAuthDisabled.toString(); + + let keyResult: DatabaseAccountListKeysResult | undefined; + // If the account has local auth disabled, don't even try to use key auth + if (!localAuthDisabled) { + keyResult = await client.databaseAccounts.listKeys(resourceGroup, name); + keyCred = keyResult?.primaryMasterKey + ? { + type: 'key', + key: keyResult.primaryMasterKey, + } + : undefined; + context.telemetry.properties.receivedKeyCreds = 'true'; + } else { + throw new Error('Local auth is disabled'); + } + } catch { + context.telemetry.properties.receivedKeyCreds = 'false'; + + const message = localize( + 'keyPermissionErrorMsg', + 'You do not have the required permissions to list auth keys for [{0}].\nFalling back to using Entra ID.\nYou can change the default authentication in the settings.', + name, + ); + const openSettingsItem = localize('openSettings', 'Open Settings'); + void vscode.window.showWarningMessage(message, ...[openSettingsItem]).then((item) => { + if (item === openSettingsItem) { + void vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'azureDatabases.useCosmosOAuth', + ); + } + }); + } + } + + // OAuth is always enabled for Cosmos DB and will be used as a fallback if key auth is unavailable + const authCred = { type: 'auth' }; + return [keyCred, authCred].filter((cred): cred is CosmosDBCredential => cred !== undefined); + }); + + return result ?? []; + } + + protected abstract getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise; +} diff --git a/src/tree/docdb/DocumentDBContainerResourceItem.ts b/src/tree/docdb/DocumentDBContainerResourceItem.ts new file mode 100644 index 000000000..7714a786f --- /dev/null +++ b/src/tree/docdb/DocumentDBContainerResourceItem.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBContainerModel } from './models/DocumentDBContainerModel'; + +export abstract class DocumentDBContainerResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.container'; + + protected constructor( + public readonly model: DocumentDBContainerModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + async getChildren(): Promise { + const triggers = await this.getChildrenTriggersImpl(); + const storedProcedures = await this.getChildrenStoredProceduresImpl(); + const items = await this.getChildrenItemsImpl(); + + return [items, storedProcedures, triggers].filter((r) => r !== undefined); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('files'), + label: this.model.container.id, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + protected abstract getChildrenTriggersImpl(): Promise; + protected abstract getChildrenStoredProceduresImpl(): Promise; + protected abstract getChildrenItemsImpl(): Promise; +} diff --git a/src/tree/docdb/DocumentDBDatabaseResourceItem.ts b/src/tree/docdb/DocumentDBDatabaseResourceItem.ts new file mode 100644 index 000000000..c199ab92b --- /dev/null +++ b/src/tree/docdb/DocumentDBDatabaseResourceItem.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type CosmosClient, type Resource } from '@azure/cosmos'; +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBDatabaseModel } from './models/DocumentDBDatabaseModel'; + +export abstract class DocumentDBDatabaseResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.database'; + + protected constructor( + public readonly model: DocumentDBDatabaseModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + async getChildren(): Promise { + const { endpoint, credentials, isEmulator } = this.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const containers = await this.getContainers(cosmosClient); + + return this.getChildrenImpl(containers); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('database'), + label: this.model.database.id, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + protected async getContainers(cosmosClient: CosmosClient): Promise<(ContainerDefinition & Resource)[]> { + const result = await cosmosClient.database(this.model.database.id).containers.readAll().fetchAll(); + return result.resources; + } + + protected abstract getChildrenImpl(containers: (ContainerDefinition & Resource)[]): Promise; +} diff --git a/src/tree/docdb/DocumentDBItemResourceItem.ts b/src/tree/docdb/DocumentDBItemResourceItem.ts new file mode 100644 index 000000000..8db80c69a --- /dev/null +++ b/src/tree/docdb/DocumentDBItemResourceItem.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { extractPartitionKey, getDocumentId } from '../../utils/document'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBItemModel } from './models/DocumentDBItemModel'; + +export abstract class DocumentDBItemResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.document'; + + protected constructor( + public readonly model: DocumentDBItemModel, + public readonly experience: Experience, + ) { + const uniqueId = this.generateUniqueId(this.model); + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/documents/${uniqueId}`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + getTreeItem(): TreeItem { + const documentId = getDocumentId(this.model.item, this.model.container.partitionKey); + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('file'), + label: documentId?.id ?? documentId?._rid ?? '', + tooltip: new vscode.MarkdownString( + `${this.generateDocumentTooltip()}\n${this.generatePartitionKeyTooltip()}`, + ), + collapsibleState: vscode.TreeItemCollapsibleState.None, + command: { + title: 'Open Document', + command: 'cosmosDB.openDocument', + }, + }; + } + + protected generateDocumentTooltip(): string { + return ( + '### Document\n' + + '---\n' + + `${this.model.item.id ? `- ID: **${this.model.item.id}**\n` : ''}` + + `${this.model.item._id ? `- ID (_id): **${this.model.item._id}**\n` : ''}` + + `${this.model.item._rid ? `- RID: **${this.model.item._rid}**\n` : ''}` + + `${this.model.item._self ? `- Self Link: **${this.model.item._self}**\n` : ''}` + + `${this.model.item._etag ? `- ETag: **${this.model.item._etag}**\n` : ''}` + + `${this.model.item._ts ? `- Timestamp: **${this.model.item._ts}**\n` : ''}` + ); + } + + protected generatePartitionKeyTooltip(): string { + if (!this.model.container.partitionKey || this.model.container.partitionKey.paths.length === 0) { + return ''; + } + + const partitionKeyPaths = this.model.container.partitionKey.paths.join(', '); + const partitionKeyValues = this.generatePartitionKeyValue(this.model); + + return ( + '### Partition Key\n' + + '---\n' + + `- Paths: **${partitionKeyPaths}**\n` + + `- Values: **${partitionKeyValues}**\n` + ); + } + + protected generateUniqueId(model: DocumentDBItemModel): string { + const documentId = getDocumentId(model.item, model.container.partitionKey); + const id = documentId?.id; + const rid = documentId?._rid; + const partitionKeyValues = this.generatePartitionKeyValue(model); + + return `${id || ''}|${partitionKeyValues || ''}|${rid || ''}`; + } + + protected generatePartitionKeyValue(model: DocumentDBItemModel): string { + if (!model.container.partitionKey || model.container.partitionKey.paths.length === 0) { + return ''; + } + + let partitionKeyValues = extractPartitionKey(model.item, model.container.partitionKey); + partitionKeyValues = Array.isArray(partitionKeyValues) ? partitionKeyValues : [partitionKeyValues]; + partitionKeyValues = partitionKeyValues + .map((v) => { + if (v === null) { + return '\\'; + } + if (v === undefined) { + return '\\'; + } + if (typeof v === 'object') { + return JSON.stringify(v); + } + return v; + }) + .join(', '); + + return partitionKeyValues; + } +} diff --git a/src/tree/docdb/DocumentDBItemsResourceItem.ts b/src/tree/docdb/DocumentDBItemsResourceItem.ts new file mode 100644 index 000000000..5a1dce80e --- /dev/null +++ b/src/tree/docdb/DocumentDBItemsResourceItem.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosClient, type FeedOptions, type ItemDefinition, type QueryIterator } from '@azure/cosmos'; +import { createContextValue, createGenericElement, type IActionContext } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { getBatchSizeSetting } from '../../utils/workspacUtils'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBItemsModel } from './models/DocumentDBItemsModel'; + +export abstract class DocumentDBItemsResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.documents'; + + protected iterator: QueryIterator | undefined; + protected cachedItems: ItemDefinition[] = []; + protected hasMoreChildren: boolean = true; + + protected constructor( + public readonly model: DocumentDBItemsModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/documents`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + public async getChildren(): Promise { + if (this.iterator && this.cachedItems.length > 0) { + // ignore + } else { + // Fetch the first batch + const batchSize = getBatchSizeSetting(); + const { endpoint, credentials, isEmulator } = this.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + this.iterator = this.getIterator(cosmosClient, { maxItemCount: batchSize }); + + await this.getItems(this.iterator); + } + + const result = await this.getChildrenImpl(this.cachedItems); + + if (this.hasMoreChildren) { + result.push( + createGenericElement({ + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('refresh'), + label: 'Load more\u2026', + id: `${this.id}/loadMore`, + commandId: 'cosmosDB.loadMore', + commandArgs: [ + this.id, + (context: IActionContext) => { + context.telemetry.properties.experience = this.experience.api; + context.telemetry.properties.parentContext = this.contextValue; + + if (this.iterator) { + return this.getItems(this.iterator); + // Then refresh the tree + } else { + return []; + } + }, + ], + }) as CosmosDBTreeElement, + ); + } + + return result; + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('files'), + label: 'Documents', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + protected getIterator(cosmosClient: CosmosClient, feedOptions: FeedOptions): QueryIterator { + return cosmosClient + .database(this.model.database.id) + .container(this.model.container.id) + .items.readAll(feedOptions); + } + + protected async getItems(iterator: QueryIterator): Promise { + const result = await iterator.fetchNext(); + const items = result.resources; + this.hasMoreChildren = result.hasMoreResults; + this.cachedItems.push(...items); + + return items; + } + + protected abstract getChildrenImpl(items: ItemDefinition[]): Promise; +} diff --git a/src/tree/docdb/DocumentDBStoredProcedureResourceItem.ts b/src/tree/docdb/DocumentDBStoredProcedureResourceItem.ts new file mode 100644 index 000000000..8ec5890b8 --- /dev/null +++ b/src/tree/docdb/DocumentDBStoredProcedureResourceItem.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBStoredProcedureModel } from './models/DocumentDBStoredProcedureModel'; + +export abstract class DocumentDBStoredProcedureResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.storedProcedure'; + + protected constructor( + public readonly model: DocumentDBStoredProcedureModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/storedProcedures/${model.procedure.id}`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('server-process'), + label: this.model.procedure.id, + collapsibleState: vscode.TreeItemCollapsibleState.None, + command: { + title: 'Open Stored Procedure', + command: 'cosmosDB.openStoredProcedure', + }, + }; + } +} diff --git a/src/tree/docdb/DocumentDBStoredProceduresResourceItem.ts b/src/tree/docdb/DocumentDBStoredProceduresResourceItem.ts new file mode 100644 index 000000000..36d7d93c8 --- /dev/null +++ b/src/tree/docdb/DocumentDBStoredProceduresResourceItem.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosClient, type Resource, type StoredProcedureDefinition } from '@azure/cosmos'; +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBStoredProceduresModel } from './models/DocumentDBStoredProceduresModel'; + +export abstract class DocumentDBStoredProceduresResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.storedProcedures'; + + protected constructor( + public readonly model: DocumentDBStoredProceduresModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/storedProcedures`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + public async getChildren(): Promise { + const { endpoint, credentials, isEmulator } = this.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const storedProcedures = await this.getStoredProcedures(cosmosClient); + + return this.getChildrenImpl(storedProcedures); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('server-process'), + label: 'StoredProcedures', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + protected async getStoredProcedures(cosmosClient: CosmosClient): Promise<(StoredProcedureDefinition & Resource)[]> { + const result = await cosmosClient + .database(this.model.database.id) + .container(this.model.container.id) + .scripts.storedProcedures.readAll() + .fetchAll(); + + return result.resources; + } + + protected abstract getChildrenImpl( + storedProcedures: (StoredProcedureDefinition & Resource)[], + ): Promise; +} diff --git a/src/tree/docdb/DocumentDBTriggerResourceItem.ts b/src/tree/docdb/DocumentDBTriggerResourceItem.ts new file mode 100644 index 000000000..1a3acd125 --- /dev/null +++ b/src/tree/docdb/DocumentDBTriggerResourceItem.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBTriggerModel } from './models/DocumentDBTriggerModel'; + +export abstract class DocumentDBTriggerResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.trigger'; + + protected constructor( + public readonly model: DocumentDBTriggerModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/triggers/${model.trigger.id}`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('zap'), + label: this.model.trigger.id, + collapsibleState: vscode.TreeItemCollapsibleState.None, + command: { + title: 'Open Trigger', + command: 'cosmosDB.openTrigger', + }, + }; + } +} diff --git a/src/tree/docdb/DocumentDBTriggersResourceItem.ts b/src/tree/docdb/DocumentDBTriggersResourceItem.ts new file mode 100644 index 000000000..e3dcd3351 --- /dev/null +++ b/src/tree/docdb/DocumentDBTriggersResourceItem.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosClient, type Resource, type TriggerDefinition } from '@azure/cosmos'; +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBTriggersModel } from './models/DocumentDBTriggersModel'; + +export abstract class DocumentDBTriggersResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.triggers'; + + protected constructor( + public readonly model: DocumentDBTriggersModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/triggers`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + public async getChildren(): Promise { + const { endpoint, credentials, isEmulator } = this.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const triggers = await this.getTriggers(cosmosClient); + + return this.getChildrenImpl(triggers); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('zap'), + label: 'Triggers', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + protected async getTriggers(cosmosClient: CosmosClient): Promise<(TriggerDefinition & Resource)[]> { + const result = await cosmosClient + .database(this.model.database.id) + .container(this.model.container.id) + .scripts.triggers.readAll() + .fetchAll(); + return result.resources; + } + + protected abstract getChildrenImpl(triggers: (TriggerDefinition & Resource)[]): Promise; +} diff --git a/src/mongo/commands/launchMongoShell.ts b/src/tree/docdb/models/DocumentDBAccountModel.ts similarity index 62% rename from src/mongo/commands/launchMongoShell.ts rename to src/tree/docdb/models/DocumentDBAccountModel.ts index 68113676d..4a1e9eda8 100644 --- a/src/mongo/commands/launchMongoShell.ts +++ b/src/tree/docdb/models/DocumentDBAccountModel.ts @@ -3,10 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; +import { type CosmosAccountModel } from '../../CosmosAccountModel'; -export function launchMongoShell(): void { - const terminal: vscode.Terminal = vscode.window.createTerminal('Mongo Shell'); - terminal.sendText(`mongo`); - terminal.show(); -} +export type DocumentDBAccountModel = CosmosAccountModel; diff --git a/src/tree/docdb/models/DocumentDBContainerModel.ts b/src/tree/docdb/models/DocumentDBContainerModel.ts new file mode 100644 index 000000000..626801afb --- /dev/null +++ b/src/tree/docdb/models/DocumentDBContainerModel.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBContainerModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBDatabaseModel.ts b/src/tree/docdb/models/DocumentDBDatabaseModel.ts new file mode 100644 index 000000000..0c442c02b --- /dev/null +++ b/src/tree/docdb/models/DocumentDBDatabaseModel.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBDatabaseModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBItemModel.ts b/src/tree/docdb/models/DocumentDBItemModel.ts new file mode 100644 index 000000000..cd7c4fb8e --- /dev/null +++ b/src/tree/docdb/models/DocumentDBItemModel.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type DatabaseDefinition, type ItemDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBItemModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; + item: ItemDefinition; +}; diff --git a/src/tree/docdb/models/DocumentDBItemsModel.ts b/src/tree/docdb/models/DocumentDBItemsModel.ts new file mode 100644 index 000000000..cb8150427 --- /dev/null +++ b/src/tree/docdb/models/DocumentDBItemsModel.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBItemsModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBStoredProcedureModel.ts b/src/tree/docdb/models/DocumentDBStoredProcedureModel.ts new file mode 100644 index 000000000..1b4e43ef2 --- /dev/null +++ b/src/tree/docdb/models/DocumentDBStoredProcedureModel.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + type ContainerDefinition, + type DatabaseDefinition, + type Resource, + type StoredProcedureDefinition, +} from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBStoredProcedureModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; + procedure: StoredProcedureDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBStoredProceduresModel.ts b/src/tree/docdb/models/DocumentDBStoredProceduresModel.ts new file mode 100644 index 000000000..0b541fac3 --- /dev/null +++ b/src/tree/docdb/models/DocumentDBStoredProceduresModel.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBStoredProceduresModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBTriggerModel.ts b/src/tree/docdb/models/DocumentDBTriggerModel.ts new file mode 100644 index 000000000..a555c9fec --- /dev/null +++ b/src/tree/docdb/models/DocumentDBTriggerModel.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + type ContainerDefinition, + type DatabaseDefinition, + type Resource, + type TriggerDefinition, +} from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBTriggerModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; + trigger: TriggerDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBTriggersModel.ts b/src/tree/docdb/models/DocumentDBTriggersModel.ts new file mode 100644 index 000000000..7e42fb09b --- /dev/null +++ b/src/tree/docdb/models/DocumentDBTriggersModel.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBTriggersModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; +}; diff --git a/src/tree/graph/GraphAccountAttachedResourceItem.ts b/src/tree/graph/GraphAccountAttachedResourceItem.ts new file mode 100644 index 000000000..17237bd0a --- /dev/null +++ b/src/tree/graph/GraphAccountAttachedResourceItem.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBAttachedAccountModel } from '../attached/CosmosDBAttachedAccountModel'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type AccountInfo } from '../docdb/AccountInfo'; +import { DocumentDBAccountAttachedResourceItem } from '../docdb/DocumentDBAccountAttachedResourceItem'; +import { GraphDatabaseResourceItem } from './GraphDatabaseResourceItem'; + +export class GraphAccountAttachedResourceItem extends DocumentDBAccountAttachedResourceItem { + constructor(account: CosmosDBAttachedAccountModel, experience: Experience) { + super(account, experience); + } + + protected getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise { + return Promise.resolve( + databases.map((db) => { + return new GraphDatabaseResourceItem( + { + accountInfo: accountInfo, + database: db, + }, + this.experience, + ); + }), + ); + } +} diff --git a/src/tree/graph/GraphAccountResourceItem.ts b/src/tree/graph/GraphAccountResourceItem.ts new file mode 100644 index 000000000..a985015c8 --- /dev/null +++ b/src/tree/graph/GraphAccountResourceItem.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type AccountInfo } from '../docdb/AccountInfo'; +import { DocumentDBAccountResourceItem } from '../docdb/DocumentDBAccountResourceItem'; +import { type DocumentDBAccountModel } from '../docdb/models/DocumentDBAccountModel'; +import { GraphDatabaseResourceItem } from './GraphDatabaseResourceItem'; + +export class GraphAccountResourceItem extends DocumentDBAccountResourceItem { + constructor(account: DocumentDBAccountModel, experience: Experience) { + super(account, experience); + } + + protected getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise { + return Promise.resolve( + databases.map((db) => { + return new GraphDatabaseResourceItem( + { + accountInfo: accountInfo, + database: db, + }, + this.experience, + ); + }), + ); + } +} diff --git a/src/tree/graph/GraphContainerResourceItem.ts b/src/tree/graph/GraphContainerResourceItem.ts new file mode 100644 index 000000000..3f225aaf8 --- /dev/null +++ b/src/tree/graph/GraphContainerResourceItem.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBContainerResourceItem } from '../docdb/DocumentDBContainerResourceItem'; +import { type DocumentDBContainerModel } from '../docdb/models/DocumentDBContainerModel'; +import { GraphItemsResourceItem } from './GraphItemsResourceItem'; +import { GraphStoredProceduresResourceItem } from './GraphStoredProceduresResourceItem'; + +export class GraphContainerResourceItem extends DocumentDBContainerResourceItem { + constructor(model: DocumentDBContainerModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenTriggersImpl(): Promise { + return Promise.resolve(undefined); + } + + protected getChildrenStoredProceduresImpl(): Promise { + return Promise.resolve(new GraphStoredProceduresResourceItem({ ...this.model }, this.experience)); + } + + protected getChildrenItemsImpl(): Promise { + return Promise.resolve(new GraphItemsResourceItem({ ...this.model }, this.experience)); + } +} diff --git a/src/tree/graph/GraphDatabaseResourceItem.ts b/src/tree/graph/GraphDatabaseResourceItem.ts new file mode 100644 index 000000000..fa0b658f0 --- /dev/null +++ b/src/tree/graph/GraphDatabaseResourceItem.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBDatabaseResourceItem } from '../docdb/DocumentDBDatabaseResourceItem'; +import { type DocumentDBDatabaseModel } from '../docdb/models/DocumentDBDatabaseModel'; +import { GraphContainerResourceItem } from './GraphContainerResourceItem'; + +export class GraphDatabaseResourceItem extends DocumentDBDatabaseResourceItem { + constructor(model: DocumentDBDatabaseModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl(containers: (ContainerDefinition & Resource)[]): Promise { + return Promise.resolve( + containers.map( + (container) => new GraphContainerResourceItem({ ...this.model, container }, this.experience), + ), + ); + } +} diff --git a/src/tree/graph/GraphItemResourceItem.ts b/src/tree/graph/GraphItemResourceItem.ts new file mode 100644 index 000000000..b62cd42b4 --- /dev/null +++ b/src/tree/graph/GraphItemResourceItem.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBItemResourceItem } from '../docdb/DocumentDBItemResourceItem'; +import { type DocumentDBItemModel } from '../docdb/models/DocumentDBItemModel'; + +export class GraphItemResourceItem extends DocumentDBItemResourceItem { + constructor(model: DocumentDBItemModel, experience: Experience) { + super(model, experience); + } +} diff --git a/src/tree/graph/GraphItemsResourceItem.ts b/src/tree/graph/GraphItemsResourceItem.ts new file mode 100644 index 000000000..691474f49 --- /dev/null +++ b/src/tree/graph/GraphItemsResourceItem.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ItemDefinition } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBItemsResourceItem } from '../docdb/DocumentDBItemsResourceItem'; +import { type DocumentDBItemsModel } from '../docdb/models/DocumentDBItemsModel'; +import { GraphItemResourceItem } from './GraphItemResourceItem'; + +export class GraphItemsResourceItem extends DocumentDBItemsResourceItem { + constructor(model: DocumentDBItemsModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl(items: ItemDefinition[]): Promise { + return Promise.resolve( + items.map((item) => new GraphItemResourceItem({ ...this.model, item }, this.experience)), + ); + } +} diff --git a/src/tree/graph/GraphStoredProcedureResourceItem.ts b/src/tree/graph/GraphStoredProcedureResourceItem.ts new file mode 100644 index 000000000..0be71766e --- /dev/null +++ b/src/tree/graph/GraphStoredProcedureResourceItem.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBStoredProcedureResourceItem } from '../docdb/DocumentDBStoredProcedureResourceItem'; +import { type DocumentDBStoredProcedureModel } from '../docdb/models/DocumentDBStoredProcedureModel'; + +export class GraphStoredProcedureResourceItem extends DocumentDBStoredProcedureResourceItem { + constructor(model: DocumentDBStoredProcedureModel, experience: Experience) { + super(model, experience); + } +} diff --git a/src/tree/graph/GraphStoredProceduresResourceItem.ts b/src/tree/graph/GraphStoredProceduresResourceItem.ts new file mode 100644 index 000000000..a98d522d2 --- /dev/null +++ b/src/tree/graph/GraphStoredProceduresResourceItem.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Resource, type StoredProcedureDefinition } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBStoredProceduresResourceItem } from '../docdb/DocumentDBStoredProceduresResourceItem'; +import { type DocumentDBStoredProceduresModel } from '../docdb/models/DocumentDBStoredProceduresModel'; +import { GraphStoredProcedureResourceItem } from './GraphStoredProcedureResourceItem'; + +export class GraphStoredProceduresResourceItem extends DocumentDBStoredProceduresResourceItem { + constructor(model: DocumentDBStoredProceduresModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl( + storedProcedures: (StoredProcedureDefinition & Resource)[], + ): Promise { + return Promise.resolve( + storedProcedures.map( + (procedure) => new GraphStoredProcedureResourceItem({ ...this.model, procedure }, this.experience), + ), + ); + } +} diff --git a/src/tree/mongo/MongoAccountModel.ts b/src/tree/mongo/MongoAccountModel.ts new file mode 100644 index 000000000..8451d85ea --- /dev/null +++ b/src/tree/mongo/MongoAccountModel.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosAccountModel } from '../CosmosAccountModel'; + +export type MongoAccountModel = CosmosAccountModel & { + connectionString?: string; +}; diff --git a/src/tree/mongo/MongoAccountResourceItem.ts b/src/tree/mongo/MongoAccountResourceItem.ts new file mode 100644 index 000000000..48e4e2eb0 --- /dev/null +++ b/src/tree/mongo/MongoAccountResourceItem.ts @@ -0,0 +1,206 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseAccountGetResults } from '@azure/arm-cosmosdb'; +import { callWithTelemetryAndErrorHandling, nonNullProp, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import ConnectionString from 'mongodb-connection-string-url'; +import { type Experience } from '../../AzureDBExperiences'; +import { ext } from '../../extensionVariables'; +import { CredentialCache } from '../../mongoClusters/CredentialCache'; +import { MongoClustersClient, type DatabaseItemModel } from '../../mongoClusters/MongoClustersClient'; +import { DatabaseItem } from '../../mongoClusters/tree/DatabaseItem'; +import { type MongoClusterModel } from '../../mongoClusters/tree/MongoClusterModel'; +import { createCosmosDBManagementClient } from '../../utils/azureClients'; +import { localize } from '../../utils/localize'; +import { CosmosAccountResourceItemBase } from '../CosmosAccountResourceItemBase'; +import { type CosmosDBTreeElement, type ExtTreeElementBase } from '../CosmosDBTreeElement'; +import { type MongoAccountModel } from './MongoAccountModel'; + +/** + * This implementation relies on information from the MongoAccountModel, i.e. + * will only behave as expected when used in the context of an Azure Subscription. + */ + +// TODO: currently MongoAccountResourceItem does not reuse MongoClusterItemBase, this will be refactored after the v1 to v2 tree migration + +export class MongoAccountResourceItem extends CosmosAccountResourceItemBase { + public declare readonly account: MongoAccountModel; + public readonly contextValue: string = 'treeItem.mongoCluster'; // TODO: this is a bug and overwrites the contextValue from the base class, fix this. + + constructor( + account: MongoAccountModel, + experience: Experience, + readonly databaseAccount?: DatabaseAccountGetResults, // TODO: exploring during v1->v2 migration + readonly isEmulator?: boolean, // TODO: exploring during v1->v2 migration + ) { + super(account, experience); + } + + async discoverConnectionString(): Promise { + const result = await callWithTelemetryAndErrorHandling( + 'cosmosDB.mongo.discoverConnectionString', + async (context: IActionContext) => { + // Create a client to interact with the MongoDB vCore management API and read the cluster details + const managementClient = await createCosmosDBManagementClient( + context, + this.account.subscription as AzureSubscription, + ); + const connectionStringsInfo = await managementClient.databaseAccounts.listConnectionStrings( + this.account.resourceGroup as string, + this.account.name, + ); + + const connectionString: URL = new URL( + nonNullProp(nonNullProp(connectionStringsInfo, 'connectionStrings')[0], 'connectionString'), + ); + + // for any Mongo connectionString, append this query param because the Cosmos Mongo API v3.6 doesn't support retrywrites + // but the newer node.js drivers started breaking this + const searchParam: string = 'retrywrites'; + if (!connectionString.searchParams.has(searchParam)) { + connectionString.searchParams.set(searchParam, 'false'); + } + + const cString = connectionString.toString(); + context.valuesToMask.push(cString); + + return cString; + }, + ); + + return result ?? undefined; + } + + async getChildren(): Promise { + ext.outputChannel.appendLine(`Cosmos DB for MongoDB (RU): Loading details for "${this.account.name}"`); + + let mongoClient: MongoClustersClient | null = null; + + // Check if credentials are cached, and return the cached client if available + if (CredentialCache.hasCredentials(this.id)) { + ext.outputChannel.appendLine( + `${this.experience.longName}: Reusing active connection details for "${this.account.name}".`, + ); + mongoClient = await MongoClustersClient.getClient(this.id); + } else { + ext.outputChannel.appendLine( + `${this.experience.longName}: Activating connection for "${this.account.name}"`, + ); + + if (this.account.subscription) { + const cString = await this.discoverConnectionString(); + this.account.connectionString = cString; + } + + if (!this.account.connectionString) { + throw new Error('Connection string not found.'); + } + + const cString = new ConnectionString(this.account.connectionString); + + // // Azure MongoDB accounts need to have the name passed in for private endpoints + // mongoClient = await connectToMongoClient( + // this.account.connectionString, + // this.databaseAccount ? nonNullProp(this.databaseAccount, 'name') : appendExtensionUserAgent(), + // ); + + //TODO: simplify the api for CrednetialCache to accept full connection strings with credentials + const username: string | undefined = cString.username; + const password: string | undefined = cString.password; + CredentialCache.setCredentials(this.id, cString.toString(), username, password); + + mongoClient = await MongoClustersClient.getClient(this.id).catch(async (error) => { + console.error(error); + // If connection fails, remove cached credentials, as they might be invalid + await MongoClustersClient.deleteClient(this.id); + CredentialCache.deleteCredentials(this.id); + return null; + }); + } + + if (!mongoClient) { + throw new Error('Failed to connect.'); + } + + // TODO: add support for single databases via connection string. move it to monogoclustersclient + // + // const databaseInConnectionString = getDatabaseNameFromConnectionString(this.account.connectionString); + // if (databaseInConnectionString && !this.isEmulator) { + // // emulator violates the connection string format + // // If the database is in the connection string, that's all we connect to (we might not even have permissions to list databases) + // databases = [ + // { + // name: databaseInConnectionString, + // empty: false, + // }, + // ]; + // } + + const databases = await mongoClient.listDatabases(); + + return databases.map((database) => { + const clusterInfo = this.account as MongoClusterModel; + clusterInfo.dbExperience = this.experience; + + // eslint-disable-next-line no-unused-vars + const databaseInfo: DatabaseItemModel = { + name: database.name, + empty: database.empty, + }; + + return new DatabaseItem(clusterInfo, databaseInfo) as ExtTreeElementBase; + }); + + // } catch (error) { + // const message = parseError(error).message; + // if (this.isEmulator && message.includes('ECONNREFUSED')) { + // // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + // error.message = `Unable to reach emulator. See ${Links.LocalConnectionDebuggingTips} for debugging tips.\n${message}`; + // } + // throw error; + // } finally { + // if (mongoClient) { + // void mongoClient.close(); + // } + // } + } + + /** + * Creates a new database in the cluster. + * + * TODO: this is a plain copy&paste from mongoclusters. It will be cleaned up in one shared base class after v1 to v2 tree migration + * + * @param _context The action context. + * @param databaseName The name of the database to create. + * @returns A boolean indicating success. + */ + /** + * Creates a new MongoDB database with the specified name. + * + * @param _context - The action context. + * @param databaseName - The name of the database to create. + * @returns A promise that resolves to a boolean indicating the success of the operation. + */ + async createDatabase(_context: IActionContext, databaseName: string): Promise { + const client = await MongoClustersClient.getClient(this.id); + + return ext.state.showCreatingChild( + this.id, + localize('mongoClusters.tree.creating', 'Creating "{0}"...', databaseName), + async (): Promise => { + // Adding a delay to ensure the "creating child" animation is visible. + // The `showCreatingChild` function refreshes the parent to show the + // "creating child" animation and label. Refreshing the parent triggers its + // `getChildren` method. If the database creation completes too quickly, + // the dummy node with the animation might be shown alongside the actual + // database entry, as it will already be available in the database. + // Note to future maintainers: Do not remove this delay. + await new Promise((resolve) => setTimeout(resolve, 250)); + return client.createDatabase(databaseName); + }, + ); + } +} diff --git a/src/tree/nosql/NoSqlAccountAttachedResourceItem.ts b/src/tree/nosql/NoSqlAccountAttachedResourceItem.ts new file mode 100644 index 000000000..749561ef5 --- /dev/null +++ b/src/tree/nosql/NoSqlAccountAttachedResourceItem.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBAttachedAccountModel } from '../attached/CosmosDBAttachedAccountModel'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type AccountInfo } from '../docdb/AccountInfo'; +import { DocumentDBAccountAttachedResourceItem } from '../docdb/DocumentDBAccountAttachedResourceItem'; +import { NoSqlDatabaseResourceItem } from './NoSqlDatabaseResourceItem'; + +export class NoSqlAccountAttachedResourceItem extends DocumentDBAccountAttachedResourceItem { + constructor(account: CosmosDBAttachedAccountModel, experience: Experience) { + super(account, experience); + } + + protected getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise { + return Promise.resolve( + databases.map((db) => { + return new NoSqlDatabaseResourceItem( + { + accountInfo: accountInfo, + database: db, + }, + this.experience, + ); + }), + ); + } +} diff --git a/src/tree/nosql/NoSqlAccountResourceItem.ts b/src/tree/nosql/NoSqlAccountResourceItem.ts new file mode 100644 index 000000000..8860dd0c0 --- /dev/null +++ b/src/tree/nosql/NoSqlAccountResourceItem.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type AccountInfo } from '../docdb/AccountInfo'; +import { DocumentDBAccountResourceItem } from '../docdb/DocumentDBAccountResourceItem'; +import { type DocumentDBAccountModel } from '../docdb/models/DocumentDBAccountModel'; +import { NoSqlDatabaseResourceItem } from './NoSqlDatabaseResourceItem'; + +export class NoSqlAccountResourceItem extends DocumentDBAccountResourceItem { + constructor(account: DocumentDBAccountModel, experience: Experience) { + super(account, experience); + } + + protected getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise { + return Promise.resolve( + databases.map((db) => { + return new NoSqlDatabaseResourceItem( + { + accountInfo: accountInfo, + database: db, + }, + this.experience, + ); + }), + ); + } +} diff --git a/src/tree/nosql/NoSqlContainerResourceItem.ts b/src/tree/nosql/NoSqlContainerResourceItem.ts new file mode 100644 index 000000000..46078a585 --- /dev/null +++ b/src/tree/nosql/NoSqlContainerResourceItem.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBContainerResourceItem } from '../docdb/DocumentDBContainerResourceItem'; +import { type DocumentDBContainerModel } from '../docdb/models/DocumentDBContainerModel'; +import { NoSqlItemsResourceItem } from './NoSqlItemsResourceItem'; +import { NoSqlStoredProceduresResourceItem } from './NoSqlStoredProceduresResourceItem'; +import { NoSqlTriggersResourceItem } from './NoSqlTriggersResourceItem'; + +export class NoSqlContainerResourceItem extends DocumentDBContainerResourceItem { + constructor(model: DocumentDBContainerModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenTriggersImpl(): Promise { + return Promise.resolve(new NoSqlTriggersResourceItem({ ...this.model }, this.experience)); + } + + protected getChildrenStoredProceduresImpl(): Promise { + return Promise.resolve(new NoSqlStoredProceduresResourceItem({ ...this.model }, this.experience)); + } + + protected getChildrenItemsImpl(): Promise { + return Promise.resolve(new NoSqlItemsResourceItem({ ...this.model }, this.experience)); + } +} diff --git a/src/tree/nosql/NoSqlDatabaseResourceItem.ts b/src/tree/nosql/NoSqlDatabaseResourceItem.ts new file mode 100644 index 000000000..8dbe327e6 --- /dev/null +++ b/src/tree/nosql/NoSqlDatabaseResourceItem.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBDatabaseResourceItem } from '../docdb/DocumentDBDatabaseResourceItem'; +import { type DocumentDBDatabaseModel } from '../docdb/models/DocumentDBDatabaseModel'; +import { NoSqlContainerResourceItem } from './NoSqlContainerResourceItem'; + +export class NoSqlDatabaseResourceItem extends DocumentDBDatabaseResourceItem { + constructor(model: DocumentDBDatabaseModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl(containers: (ContainerDefinition & Resource)[]): Promise { + return Promise.resolve( + containers.map( + (container) => new NoSqlContainerResourceItem({ ...this.model, container }, this.experience), + ), + ); + } +} diff --git a/src/tree/nosql/NoSqlItemResourceItem.ts b/src/tree/nosql/NoSqlItemResourceItem.ts new file mode 100644 index 000000000..9620a738f --- /dev/null +++ b/src/tree/nosql/NoSqlItemResourceItem.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBItemResourceItem } from '../docdb/DocumentDBItemResourceItem'; +import { type DocumentDBItemModel } from '../docdb/models/DocumentDBItemModel'; + +export class NoSqlItemResourceItem extends DocumentDBItemResourceItem { + constructor(model: DocumentDBItemModel, experience: Experience) { + super(model, experience); + } +} diff --git a/src/tree/nosql/NoSqlItemsResourceItem.ts b/src/tree/nosql/NoSqlItemsResourceItem.ts new file mode 100644 index 000000000..42da5ab92 --- /dev/null +++ b/src/tree/nosql/NoSqlItemsResourceItem.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ItemDefinition } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBItemsResourceItem } from '../docdb/DocumentDBItemsResourceItem'; +import { type DocumentDBItemsModel } from '../docdb/models/DocumentDBItemsModel'; +import { NoSqlItemResourceItem } from './NoSqlItemResourceItem'; + +export class NoSqlItemsResourceItem extends DocumentDBItemsResourceItem { + constructor(model: DocumentDBItemsModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl(items: ItemDefinition[]): Promise { + return Promise.resolve( + items.map((item) => new NoSqlItemResourceItem({ ...this.model, item }, this.experience)), + ); + } +} diff --git a/src/tree/nosql/NoSqlStoredProcedureResourceItem.ts b/src/tree/nosql/NoSqlStoredProcedureResourceItem.ts new file mode 100644 index 000000000..9ef0874af --- /dev/null +++ b/src/tree/nosql/NoSqlStoredProcedureResourceItem.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBStoredProcedureResourceItem } from '../docdb/DocumentDBStoredProcedureResourceItem'; +import { type DocumentDBStoredProcedureModel } from '../docdb/models/DocumentDBStoredProcedureModel'; + +export class NoSqlStoredProcedureResourceItem extends DocumentDBStoredProcedureResourceItem { + constructor(model: DocumentDBStoredProcedureModel, experience: Experience) { + super(model, experience); + } +} diff --git a/src/tree/nosql/NoSqlStoredProceduresResourceItem.ts b/src/tree/nosql/NoSqlStoredProceduresResourceItem.ts new file mode 100644 index 000000000..760926298 --- /dev/null +++ b/src/tree/nosql/NoSqlStoredProceduresResourceItem.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Resource, type StoredProcedureDefinition } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBStoredProceduresResourceItem } from '../docdb/DocumentDBStoredProceduresResourceItem'; +import { type DocumentDBStoredProceduresModel } from '../docdb/models/DocumentDBStoredProceduresModel'; +import { NoSqlStoredProcedureResourceItem } from './NoSqlStoredProcedureResourceItem'; + +export class NoSqlStoredProceduresResourceItem extends DocumentDBStoredProceduresResourceItem { + constructor(model: DocumentDBStoredProceduresModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl( + storedProcedures: (StoredProcedureDefinition & Resource)[], + ): Promise { + return Promise.resolve( + storedProcedures.map( + (procedure) => new NoSqlStoredProcedureResourceItem({ ...this.model, procedure }, this.experience), + ), + ); + } +} diff --git a/src/tree/nosql/NoSqlTriggerResourceItem.ts b/src/tree/nosql/NoSqlTriggerResourceItem.ts new file mode 100644 index 000000000..52a6f0f25 --- /dev/null +++ b/src/tree/nosql/NoSqlTriggerResourceItem.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBTriggerResourceItem } from '../docdb/DocumentDBTriggerResourceItem'; +import { type DocumentDBTriggerModel } from '../docdb/models/DocumentDBTriggerModel'; + +export class NoSqlTriggerResourceItem extends DocumentDBTriggerResourceItem { + constructor(model: DocumentDBTriggerModel, experience: Experience) { + super(model, experience); + } +} diff --git a/src/tree/nosql/NoSqlTriggersResourceItem.ts b/src/tree/nosql/NoSqlTriggersResourceItem.ts new file mode 100644 index 000000000..b8569394f --- /dev/null +++ b/src/tree/nosql/NoSqlTriggersResourceItem.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Resource, type TriggerDefinition } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBTriggersResourceItem } from '../docdb/DocumentDBTriggersResourceItem'; +import { type DocumentDBTriggersModel } from '../docdb/models/DocumentDBTriggersModel'; +import { NoSqlTriggerResourceItem } from './NoSqlTriggerResourceItem'; + +export class NoSqlTriggersResourceItem extends DocumentDBTriggersResourceItem { + constructor(model: DocumentDBTriggersModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl(triggers: (TriggerDefinition & Resource)[]): Promise { + return Promise.resolve( + triggers.map((trigger) => new NoSqlTriggerResourceItem({ ...this.model, trigger }, this.experience)), + ); + } +} diff --git a/src/tree/table/TableAccountAttachedResourceItem.ts b/src/tree/table/TableAccountAttachedResourceItem.ts new file mode 100644 index 000000000..6950cb746 --- /dev/null +++ b/src/tree/table/TableAccountAttachedResourceItem.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + createGenericElement, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBAttachedAccountModel } from '../attached/CosmosDBAttachedAccountModel'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBAccountAttachedResourceItem } from '../docdb/DocumentDBAccountAttachedResourceItem'; + +export class TableAccountAttachedResourceItem extends DocumentDBAccountAttachedResourceItem { + constructor(account: CosmosDBAttachedAccountModel, experience: Experience) { + super(account, experience); + } + + public async getChildren(): Promise { + const result = await callWithTelemetryAndErrorHandling('getChildren', (context: IActionContext) => { + context.telemetry.properties.experience = this.experience.api; + context.telemetry.properties.parentContext = this.contextValue; + + return Promise.resolve([ + createGenericElement({ + contextValue: `${this.contextValue}/notSupported`, + label: 'Table Accounts are not supported yet.', + id: `${this.id}/notSupported`, + }) as CosmosDBTreeElement, + ]); + }); + + return result ?? []; + } + + protected getChildrenImpl(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/tree/table/TableAccountResourceItem.ts b/src/tree/table/TableAccountResourceItem.ts new file mode 100644 index 000000000..262d89f3e --- /dev/null +++ b/src/tree/table/TableAccountResourceItem.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + createGenericElement, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBAccountResourceItem } from '../docdb/DocumentDBAccountResourceItem'; +import { type DocumentDBAccountModel } from '../docdb/models/DocumentDBAccountModel'; + +export class TableAccountResourceItem extends DocumentDBAccountResourceItem { + constructor(account: DocumentDBAccountModel, experience: Experience) { + super(account, experience); + } + + public async getChildren(): Promise { + const result = await callWithTelemetryAndErrorHandling('getChildren', (context: IActionContext) => { + context.telemetry.properties.experience = this.experience.api; + context.telemetry.properties.parentContext = this.contextValue; + + return Promise.resolve([ + createGenericElement({ + contextValue: `${this.contextValue}/notSupported`, + label: 'Table Accounts are not supported yet.', + id: `${this.id}/notSupported`, + }) as CosmosDBTreeElement, + ]); + }); + + return result ?? []; + } + + protected getChildrenImpl(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/tree/workspace/sharedWorkspaceResourceProvider.ts b/src/tree/workspace/SharedWorkspaceResourceProvider.ts similarity index 90% rename from src/tree/workspace/sharedWorkspaceResourceProvider.ts rename to src/tree/workspace/SharedWorkspaceResourceProvider.ts index 83df9f50f..c7de7c34a 100644 --- a/src/tree/workspace/sharedWorkspaceResourceProvider.ts +++ b/src/tree/workspace/SharedWorkspaceResourceProvider.ts @@ -32,6 +32,7 @@ import type * as vscode from 'vscode'; */ export enum WorkspaceResourceType { MongoClusters = 'vscode.cosmosdb.workspace.mongoclusters-resourceType', + AttachedAccounts = 'vscode.cosmosdb.workspace.attachedaccounts-resourceType', } /** @@ -57,6 +58,11 @@ export class SharedWorkspaceResourceProvider implements WorkspaceResourceProvide id: 'vscode.cosmosdb.workspace.mongoclusters', name: 'MongoDB Cluster Accounts', // this name will be displayed in the workspace view, when no WorkspaceResourceBranchDataProvider is registered }, + { + resourceType: WorkspaceResourceType.AttachedAccounts, + id: 'vscode.cosmosdb.workspace.attachedaccounts', + name: 'Attached Database Accounts', + }, ]; } } diff --git a/src/tree/workspace/sharedWorkspaceStorage.ts b/src/tree/workspace/SharedWorkspaceStorage.ts similarity index 99% rename from src/tree/workspace/sharedWorkspaceStorage.ts rename to src/tree/workspace/SharedWorkspaceStorage.ts index 68f903812..800b0ecd2 100644 --- a/src/tree/workspace/sharedWorkspaceStorage.ts +++ b/src/tree/workspace/SharedWorkspaceStorage.ts @@ -25,7 +25,7 @@ export type SharedWorkspaceStorageItem = { /** * Optional properties associated with the item. */ - properties?: Record; + properties?: Record; /** * Optional array of secrets associated with the item. diff --git a/src/utils/InteractiveChildProcess.ts b/src/utils/InteractiveChildProcess.ts index 8015f3745..de55c4770 100644 --- a/src/utils/InteractiveChildProcess.ts +++ b/src/utils/InteractiveChildProcess.ts @@ -67,13 +67,11 @@ export class InteractiveChildProcess { } public writeLine(text: string): void { - this.writeLineToOutputChannel(text, stdInPrefix); this._childProc.stdin?.write(text + os.EOL); } private async startCore(): Promise { this._startTime = Date.now(); - const formattedArgs: string = this._options.args.join(' '); const workingDirectory = this._options.workingDirectory || os.tmpdir(); const options: cp.SpawnOptions = { @@ -85,13 +83,12 @@ export class InteractiveChildProcess { shell: false, }; - this.writeLineToOutputChannel(`Starting executable: "${this._options.command}" ${formattedArgs}`); + this.writeLineToOutputChannel(`Starting executable: "${this._options.command}"`); this._childProc = cp.spawn(this._options.command, this._options.args, options); this._childProc.stdout?.on('data', (data: string | Buffer) => { const text = data.toString(); this._onStdOutEmitter.fire(text); - this.writeLineToOutputChannel(text); }); this._childProc.stderr?.on('data', (data: string | Buffer) => { @@ -111,6 +108,7 @@ export class InteractiveChildProcess { } else if (!this._isKilling) { this.setError(`The process exited prematurely.`); } + this.writeLineToOutputChannel(`Process exited: "${this._options.command}"`); }); // Wait for the process to start up diff --git a/src/utils/activityUtils.ts b/src/utils/activityUtils.ts index 652c0b8d6..a8c12a37a 100644 --- a/src/utils/activityUtils.ts +++ b/src/utils/activityUtils.ts @@ -5,15 +5,28 @@ import { type ExecuteActivityContext } from '@microsoft/vscode-azext-utils'; import { ext } from '../extensionVariables'; -import { getWorkspaceSetting } from './settingUtils'; +import { SettingsService } from '../services/SettingsService'; -export async function createActivityContext(): Promise { +export async function createActivityContext(withChildren?: boolean): Promise { return { registerActivity: async (activity) => ext.rgApi.registerActivity(activity), - suppressNotification: await getWorkspaceSetting( + suppressNotification: await SettingsService.getSetting( 'suppressActivityNotifications', undefined, 'azureResourceGroups', ), + activityChildren: withChildren ? [] : undefined, + }; +} + +export async function createActivityContextV2(withChildren?: boolean): Promise { + return { + registerActivity: async (activity) => ext.rgApiV2.activity.registerActivity(activity), + suppressNotification: await SettingsService.getSetting( + 'suppressActivityNotifications', + undefined, + 'azureResourceGroups', + ), + activityChildren: withChildren ? [] : undefined, }; } diff --git a/src/utils/azureClients.ts b/src/utils/azureClients.ts index 8762db87d..26b40803f 100644 --- a/src/utils/azureClients.ts +++ b/src/utils/azureClients.ts @@ -17,6 +17,14 @@ export async function createCosmosDBClient(context: AzExtClientContext): Promise return createAzureClient(context, (await import('@azure/arm-cosmosdb')).CosmosDBManagementClient); } +export async function createCosmosDBManagementClient( + context: IActionContext, + subscription: AzureSubscription, +): Promise { + const subContext = createSubscriptionContext(subscription); + return createAzureClient([context, subContext], (await import('@azure/arm-cosmosdb')).CosmosDBManagementClient); +} + export async function createMongoClustersManagementClient( context: IActionContext, subscription: AzureSubscription, diff --git a/src/utils/document.ts b/src/utils/document.ts index 374127354..3b75b8994 100644 --- a/src/utils/document.ts +++ b/src/utils/document.ts @@ -22,7 +22,7 @@ export const extractPartitionKey = (document: ItemDefinition, partitionKey: Part // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment interim = interim[prop]; } else { - return null; // It is not correct to return null, in other cases it should exception + return null; // It is not correct to return null, in other cases it should be an exception } } if ( diff --git a/src/utils/settingUtils.ts b/src/utils/settingUtils.ts deleted file mode 100644 index af837141d..000000000 --- a/src/utils/settingUtils.ts +++ /dev/null @@ -1,74 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ConfigurationTarget, Uri, workspace, type WorkspaceConfiguration } from 'vscode'; -import { ext } from '../extensionVariables'; - -/** - * Uses ext.prefix 'azureDatabases' unless otherwise specified - */ -export async function updateGlobalSetting( - section: string, - value: T, - prefix: string = ext.prefix, -): Promise { - const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix); - await projectConfiguration.update(section, value, ConfigurationTarget.Global); -} - -/** - * Uses ext.prefix 'azureDatabases' unless otherwise specified - */ -export async function updateWorkspaceSetting( - section: string, - value: T, - fsPath: string, - prefix: string = ext.prefix, -): Promise { - const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix, Uri.file(fsPath)); - await projectConfiguration.update(section, value); -} - -/** - * Uses ext.prefix 'azureDatabases' unless otherwise specified - */ -export function getGlobalSetting(key: string, prefix: string = ext.prefix): T | undefined { - const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix); - const result: { globalValue?: T } | undefined = projectConfiguration.inspect(key); - return result && result.globalValue; -} - -/** - * Uses ext.prefix 'azureDatabases' unless otherwise specified - */ -export function getWorkspaceSetting(key: string, fsPath?: string, prefix: string = ext.prefix): T | undefined { - const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration( - prefix, - fsPath ? Uri.file(fsPath) : undefined, - ); - return projectConfiguration.get(key); -} - -/** - * Searches through all open folders and gets the current workspace setting (as long as there are no conflicts) - * Uses ext.prefix 'azureDatabases' unless otherwise specified - */ -export function getWorkspaceSettingFromAnyFolder(key: string, prefix: string = ext.prefix): string | undefined { - if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { - let result: string | undefined; - for (const folder of workspace.workspaceFolders) { - const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix, folder.uri); - const folderResult: string | undefined = projectConfiguration.get(key); - if (!result) { - result = folderResult; - } else if (folderResult && result !== folderResult) { - return undefined; - } - } - return result; - } else { - return getGlobalSetting(key, prefix); - } -} diff --git a/src/utils/withProgress.ts b/src/utils/withProgress.ts new file mode 100644 index 000000000..46d58a47e --- /dev/null +++ b/src/utils/withProgress.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export function withProgress( + promise: Thenable, + title: string, + location: vscode.ProgressLocation = vscode.ProgressLocation.Notification, +): Thenable { + return vscode.window.withProgress( + { + location: location, + title: title, + }, + (_progress) => { + return promise; + }, + ); +} diff --git a/src/webviews/mongoClusters/collectionView/collectionViewController.ts b/src/webviews/mongoClusters/collectionView/collectionViewController.ts index ea7324e83..7b17208e8 100644 --- a/src/webviews/mongoClusters/collectionView/collectionViewController.ts +++ b/src/webviews/mongoClusters/collectionView/collectionViewController.ts @@ -13,6 +13,7 @@ export type CollectionViewWebviewConfigurationType = { id: string; // move to base type sessionId: string; + clusterId: string; databaseName: string; collectionName: string; collectionTreeItem: CollectionItem; // needed to execute commands on the collection as the tree APIv2 doesn't support id-based search for tree items. @@ -31,6 +32,7 @@ export class CollectionViewController extends WebviewController { assert(mongoDErrors === ''); let previousEnv: IDisposable | undefined; - let shell: MongoShell | undefined; + let shell: MongoShellScriptRunner | undefined; const outputChannel = new FakeOutputChannel(); try { previousEnv = setEnvironmentVariables(options.env || {}); - shell = await MongoShell.create( + shell = await MongoShellScriptRunner.createShellProcessHelper( options.mongoPath || mongoPath, options.args || [], '', @@ -291,7 +292,14 @@ suite('MongoShell', async function (this: Mocha.Suite): Promise { }); await testIfSupported("More results than displayed (type 'it' for more -> (More))", async () => { - const shell = await MongoShell.create(mongoPath, [], '', false, new FakeOutputChannel(), 5); + const shell = await MongoShellScriptRunner.createShellProcessHelper( + mongoPath, + [], + '', + false, + new FakeOutputChannel(), + 5, + ); await shell.executeScript('db.mongoShellTest.drop()'); await shell.executeScript('for (var i = 0; i < 50; ++i) { db.mongoShellTest.insert({a:i}); }'); diff --git a/test/runWithSetting.ts b/test/runWithSetting.ts index eb0e5df71..97d42b537 100644 --- a/test/runWithSetting.ts +++ b/test/runWithSetting.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ext, getGlobalSetting, updateGlobalSetting } from '../extension.bundle'; +import { ext } from '../extension.bundle'; +import { SettingsService } from '../src/services/SettingsService'; export async function runWithDatabasesSetting( key: string, @@ -27,11 +28,11 @@ async function runWithSettingInternal( prefix: string, callback: () => Promise, ): Promise { - const oldValue: string | boolean | undefined = getGlobalSetting(key, prefix); + const oldValue: string | boolean | undefined = SettingsService.getGlobalSetting(key, prefix); try { - await updateGlobalSetting(key, value, prefix); + await SettingsService.updateGlobalSetting(key, value, prefix); await callback(); } finally { - await updateGlobalSetting(key, oldValue, prefix); + await SettingsService.updateGlobalSetting(key, oldValue, prefix); } }