diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1441926aa..2c1f2fb2b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ -/.azure-pipelines @Microsoft/documentdb-for-vscode-engineering -/.config @Microsoft/documentdb-for-vscode-engineering -* @Microsoft/documentdb-for-vscode-engineering +/.azure-pipelines @microsoft/documentdb-for-vscode-core-engineering +/.config @microsoft/documentdb-for-vscode-core-engineering +* @microsoft/documentdb-for-vscode-core-engineering diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6f8332db3..a8fd8e834 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -68,6 +68,7 @@ This document provides comprehensive guidelines and context for GitHub Copilot t - Use l10n for any user-facing strings with `vscode.l10n.t()`. - Use `npm run prettier-fix` to format your code before committing. - Use `npm run lint` to check for linting errors. +- Use `npm run build` to ensure the project builds successfully (do not use `npm run compile`). - Use `npm run l10n` to update localization files in case you change any user-facing strings. - Ensure TypeScript compilation passes without errors. @@ -422,3 +423,69 @@ const message = vscode.l10n.t( - Ensure compatibility with Node.js version specified in `.nvmrc`. - Follow the project's ESLint configuration for consistent code style. - Use webpack for bundling and ensure proper tree-shaking. + +--- + +## Null Safety with nonNull Helpers + +**Always use the nonNull utility functions** from `src/utils/nonNull.ts` instead of manual null checks for better error reporting and debugging. + +#### Available Functions + +- **`nonNullProp()`**: Extract and validate object properties +- **`nonNullValue()`**: Validate any value is not null/undefined +- **`nonNullOrEmptyValue()`**: Validate strings are not null/undefined/empty + +#### Parameter Guidelines + +Both `message` and `details` parameters are **required** for all nonNull functions: + +- **`message`**: Use the actual member access or assignment LHS from your code. Since this is open source, use real variable names: + - Member access: `'selectedItem.cluster.connectionString'` + - Wizard context: `'wizardContext.password'` + - Local variables: `'connectionString.match(...)'` + +- **`details`**: Use the actual file base name where the code is located: + - Examples: `'ExecuteStep.ts'`, `'ConnectionItem.ts'`, `'DatabaseTreeItem.ts'` + - Keep it short, use the actual file name, don't create constants + +#### Usage Examples + +```typescript +// ✅ Good - Property extraction with validation +const connectionString = nonNullProp( + selectedItem.cluster, + 'connectionString', + 'selectedItem.cluster.connectionString', + 'ExecuteStep.ts', +); + +// ✅ Good - Value validation +const validatedConnection = nonNullValue(await getConnection(id), 'getConnection(id)', 'ConnectionManager.ts'); + +// ✅ Good - String validation (not empty) +const databaseName = nonNullOrEmptyValue( + wizardContext.databaseName, + 'wizardContext.databaseName', + 'CreateDatabaseStep.ts', +); + +// ✅ Good - Manual null check for user-facing validation +if (!userInput.connectionString) { + void vscode.window.showErrorMessage(vscode.l10n.t('Connection string is required')); + return; +} + +// ❌ Bad - Manual null checks for internal validation (use nonNull helpers instead) +if (!selectedItem.cluster.connectionString) { + throw new Error('Connection string is required'); // This should use nonNullProp +} + +// ❌ Bad - Generic parameter values +const value = nonNullValue(data, 'some value', 'file.ts'); +``` + +**When to use each approach:** + +- **Use nonNull helpers**: For internal validation where you expect the value to exist (programming errors) +- **Use manual checks**: For user-facing validation with localized error messages shown to users diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2aedc58f8..879da1ee7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -60,7 +60,7 @@ jobs: ${{ runner.os }}-node- - name: 📦 Install Dependencies (npm ci) - run: npm ci + run: npm ci --verbose - name: 🌐 Check Localization Files run: npm run l10n:check diff --git a/.vscode/launch.json b/.vscode/launch.json index cd5908a07..995f5b142 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,36 +3,7 @@ "version": "0.2.0", "configurations": [ { - "name": "Launch Extension", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceFolder}"], - "outFiles": ["${workspaceFolder}/out/**/*.js"], - "preLaunchTask": "${defaultBuildTask}", - "env": { - "DEBUGTELEMETRY": "true", // set this to "verbose" to see telemetry events in debug console - "NODE_DEBUG": "" - } - }, - { - "name": "Launch Extension + Host", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionDevelopmentPath=${workspaceFolder}/../vscode-azureresourcegroups" - ], - "outFiles": ["${workspaceFolder}/out/**/*.js"], - "preLaunchTask": "${defaultBuildTask}", - "env": { - "DEBUGTELEMETRY": "verbose", // set this to "true" to suppress telemetry events in debug console - "NODE_DEBUG": "" - } - }, - { - "name": "Launch Extension (webpack)", + "name": "Defaut: Launch Extension (webpack)", "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", @@ -64,7 +35,7 @@ } }, { - "name": "Launch Extension + Host (webpack)", + "name": "Launch Extension + AzureResources (webpack)", "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", @@ -74,11 +45,23 @@ "--extensionDevelopmentPath=${workspaceFolder}/../vscode-azureresourcegroups" ], "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/../vscode-azureresourcegroups/out/**/*.js"], + "preLaunchTask": "Watch", + "autoAttachChildProcesses": true, + "debugWebWorkerHost": true, + "debugWebviews": false, + "trace": true, + "sourceMaps": true, + "pauseForSourceMap": true, + "skipFiles": ["**/node_modules/**"], + "smartStep": true, + "sourceMapRenames": true, + "rendererDebugOptions": { + "webRoot": "${workspaceFolder}" + }, "resolveSourceMapLocations": ["${workspaceFolder}/dist/**", "!**/node_modules/**"], "sourceMapPathOverrides": { "./*": "${workspaceFolder}/*" }, - "preLaunchTask": "Watch", "env": { "DEBUGTELEMETRY": "verbose", // set this to "true" to suppress telemetry events in debug console "NODE_DEBUG": "", @@ -86,19 +69,22 @@ "DEVSERVER": "true", "STOP_ON_ENTRY": "false" // stop on entry is not allowed for "type": "extensionHost", therefore, it's emulated here (review main.ts) }, - "debugWebWorkerHost": true, - "rendererDebugOptions": { - "pauseForSourceMap": true, - "sourceMapRenames": true, - "sourceMaps": true, - "webRoot": "${workspaceFolder}/src/webviews/" - }, - "skipFiles": ["**/node_modules/**"], - "smartStep": true, - "sourceMapRenames": true, - "sourceMaps": true, - "pauseForSourceMap": true, - "trace": true + }, + { + "name": "Launch Extension + Host", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionDevelopmentPath=${workspaceFolder}/../vscode-azureresourcegroups" + ], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "${defaultBuildTask}", + "env": { + "DEBUGTELEMETRY": "verbose", // set this to "true" to suppress telemetry events in debug console + "NODE_DEBUG": "" + } }, { "name": "Launch Tests", diff --git a/CHANGELOG.md b/CHANGELOG.md index 28af165ea..5577a5269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## 0.4.0 + +### New Features & Improvements + +- **Deep Azure Integration**: Introduces deep integration with the Azure Resources extension, providing a unified experience for discovering and managing Azure Cosmos DB for MongoDB (RU and vCore) resources directly within the Azure view. [#58](https://github.com/microsoft/vscode-documentdb/issues/58) +- **Service Discovery for MongoDB (RU)**: Adds a new service discovery provider for Azure Cosmos DB for MongoDB (RU) resources, enabling effortless connection and authentication through the Discovery View. [#244](https://github.com/microsoft/vscode-documentdb/issues/244) +- **Official DocumentDB Logo**: Updated the extension's icon and branding to use the official DocumentDB logo for better brand recognition and consistency. [#246](https://github.com/microsoft/vscode-documentdb/pull/246) + +### Fixes + +- **Connection String Password Support**: Restored support for passwords when creating new connections using a connection string, fixing a regression that affected certain configurations. [#247](https://github.com/microsoft/vscode-documentdb/pull/247) +- **Improved Debugging Information**: Enhanced internal error handling for `nonNull` checks to include file context, making it easier to diagnose and triage issues. [#236](https://github.com/microsoft/vscode-documentdb/pull/236) + ## 0.3.1 ### Fixes diff --git a/README.md b/README.md index 22f2f49ff..80366fc35 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Version](https://img.shields.io/visual-studio-marketplace/v/ms-azuretools.vscode-documentdb.svg)](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-documentdb) +DocumentDB Logo **A powerful, open-source DocumentDB and MongoDB GUI for everyone.** diff --git a/docs/index.md b/docs/index.md index 3fcac2fbf..c3183d3b4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,7 +62,8 @@ This section contains detailed documentation for specific features and concepts Explore the history of updates and improvements to the DocumentDB for VS Code extension. Each release brings new features, enhancements, and fixes to improve your experience. -- [0.3](./release-notes/0.3.md) +- [0.4](./release-notes/0.4.md) +- [0.3, 0.3.1](./release-notes/0.3.md) - [0.2.4](./release-notes/0.2.4.md) - [0.2.3](./release-notes/0.2.3.md) - [0.2.2](./release-notes/0.2.2.md) diff --git a/docs/learn-more/service-discovery-azure-cosmosdb-for-mongodb-ru.md b/docs/learn-more/service-discovery-azure-cosmosdb-for-mongodb-ru.md new file mode 100644 index 000000000..3df3b4ff5 --- /dev/null +++ b/docs/learn-more/service-discovery-azure-cosmosdb-for-mongodb-ru.md @@ -0,0 +1,49 @@ + + + +> **Learn More** — [Back to Learn More Index](./index) + +--- + +# Azure CosmosDB for MongoDB (RU) Service Discovery Plugin + +The **Azure CosmosDB for MongoDB (RU)** plugin is available as part of the [Service Discovery](./service-discovery) feature in DocumentDB for VS Code. This plugin helps you find and connect to Azure Cosmos DB accounts provisioned with Request Units (RU) for the MongoDB API by handling authentication, resource discovery, and connection creation from inside the extension. + +## How to Access + +You can access this plugin in two ways: + +- From the `Service Discovery` panel in the extension sidebar. +- When adding a new connection, select the `Azure CosmosDB for MongoDB (RU)` option. + +![Service Discovery Activation](./images/service-discovery-activation.png) + +## How It Works + +When you use the Azure CosmosDB for MongoDB (RU) plugin, the extension performs the following steps: + +1. **Authentication:** + The plugin uses your Azure credentials available in VS Code. If needed, it will prompt you to sign in via the standard Azure sign-in flows. + +2. **Subscription Discovery:** + The plugin lists subscriptions available to your account so you can pick where to search for resources. + +3. **Account Discovery:** + The provider queries Azure using the CosmosDB Management Client and filters results by the MongoDB "kind" for RU-based accounts. This ensures the list contains accounts that support the MongoDB API under RU provisioning. + +4. **Connection Options:** + - Expand an account entry to view databases and connection options. + - Save an account to your `DocumentDB Connections` list using the context menu or the save icon next to its name. + - When connecting or saving, the extension will extract credentials or connection details from Azure where available. If multiple authentication methods are supported, you will be prompted to choose one. + +## Additional Notes + +- You can filter subscriptions in the Service Discovery panel to limit the scope of discovery if you have access to many subscriptions. +- The provider reuses shared authentication and subscription selection flows used across other Service Discovery plugins. +- If you save a discovered account, the saved connection will appear in your Connections view for later use. + +## Feedback and Contributions + +If you have suggestions for improving this provider or would like to add support for additional resource types, please [join the discussion board](https://github.com/microsoft/vscode-documentdb/discussions) and share your feedback. + +--- diff --git a/docs/learn-more/service-discovery.md b/docs/learn-more/service-discovery.md index f37b48bc0..07b621ffb 100644 --- a/docs/learn-more/service-discovery.md +++ b/docs/learn-more/service-discovery.md @@ -23,8 +23,9 @@ This approach allows you to connect to a variety of platforms without needing to ## Available Service Discovery Plugins -Currently, two service discovery plugins are available: +Currently, the following service discovery plugins are available: +- **[Azure CosmosDB for MongoDB (RU)](./service-discovery-azure-cosmosdb-for-mongodb-ru)** - **[Azure CosmosDB for MongoDB (vCore)](./service-discovery-azure-cosmosdb-for-mongodb-vcore)** - **[Azure VMs (DocumentDB)](./service-discovery-azure-vms)** diff --git a/docs/release-notes/0.4.md b/docs/release-notes/0.4.md new file mode 100644 index 000000000..14003ddbc --- /dev/null +++ b/docs/release-notes/0.4.md @@ -0,0 +1,62 @@ + + +> **Release Notes** — [Back to Home](../index.md) + +--- + +# DocumentDB for VS Code Extension v0.4 + +We are excited to announce the release of **DocumentDB for VS Code Extension v0.4**. This is a landmark update that introduces deep integration with the Azure ecosystem, adds powerful new service discovery capabilities, and aligns our branding with the official [DocumentDB](https://documentdb.io) identity. + +

DocumentDB Logo

+ +This release improves how developers working with Azure discover, connect to, and manage their MongoDB-compatible databases, all from within a unified VS Code experience. + +## What's New in v0.4 + +### ⭐ **Deep Integration with the Azure Resources Extension** ([#58](https://github.com/microsoft/vscode-documentdb/issues/58)) + +> **Note: This is a staged feature release** +> It is coordinated across three extensions: DocumentDB for VS Code, Azure Databases, and Azure Resources. +> +> The full integration experience will be enabled when the Azure Resources extension update is released in the coming days. + +This release improves the user experience for developers in the Azure ecosystem by integrating directly with the **Azure Resources extension**. The DocumentDB extension now takes ownership of the **Azure Cosmos DB for MongoDB (RU)** and **(vCore)** nodes directly within the Azure resource tree. + +This collaboration provides a single, authoritative view for all your Azure resources while enriching the experience with the specialized MongoDB tooling that our extension provides. + +- **Unified Azure View**: Discover and manage your MongoDB resources directly within the familiar Azure Resources view without needing to switch between extensions. +- **Rich MongoDB Tooling**: Access all of DocumentDB for VS Code's features - like data exploration, query scrapbooks, and management commands - directly from the Azure tree. +- **Coordinated Orchestration**: The Azure Resources extension now directs which extension manages which resource, ensuring a stable and conflict-free experience. +- **Automatic Migration**: Existing MongoDB connections are automatically migrated for an efficient transition. + +

Authentication Method Selection

+ +### 2️⃣ **Service Discovery for Azure Cosmos DB for MongoDB (RU)** ([#244](https://github.com/microsoft/vscode-documentdb/issues/244)) + +We've expanded our service discovery capabilities by adding a dedicated provider for **Azure Cosmos DB for MongoDB (RU)** resources. This complements our existing vCore provider and makes connecting to RU-based accounts easier than ever. + +- **New Discovery Option**: A new "Azure Cosmos DB for MongoDB (RU)" provider is now available in the Discovery View. +- **Consistent User Experience**: The new provider uses the same authentication and wizard-based workflow (select subscription → select cluster → connect) that users are already familiar with. +- **Optimized for RU**: The provider uses RU-specific Azure APIs to ensure accurate and reliable discovery. + +[Learn more about Service Discovery →](../learn-more/service-discovery.md) + +### 3️⃣ **Official DocumentDB Logo and Branding** ([#246](https://github.com/microsoft/vscode-documentdb/pull/246)) + +The extension has been updated with the **official DocumentDB logo and branding**. This change provides better brand recognition and creates a more consistent visual identity across the DocumentDB ecosystem. You'll see the new logo in the VS Code activity bar, the marketplace, and throughout our documentation. + +## Key Fixes and Improvements + +#### 🐛 **Restored Password Support for Connection Strings** ([#247](https://github.com/microsoft/vscode-documentdb/pull/247)) + +We've fixed a regression that prevented users from creating new connections using a connection string with a password in certain configurations. The validation logic in the connection workflow has been improved to ensure all valid connection strings are handled correctly. + +#### 🛠️ **Improved Debugging with Enhanced Error Details** ([#236](https://github.com/microsoft/vscode-documentdb/pull/236)) + +As part of our commitment to stability, we've enhanced our internal error handling. `nonNull` checks now include file context in their error messages, making it significantly easier for our team to diagnose and triage reported issues. + +## Changelog + +See the full changelog entry for this release: +➡️ [CHANGELOG.md#040](https://github.com/microsoft/vscode-documentdb/blob/main/CHANGELOG.md#040) diff --git a/docs/release-notes/images/0.4.0_azure_resources.png b/docs/release-notes/images/0.4.0_azure_resources.png new file mode 100644 index 000000000..c4445b811 Binary files /dev/null and b/docs/release-notes/images/0.4.0_azure_resources.png differ diff --git a/docs/release-notes/images/0.4.0_documentdb-logo.png b/docs/release-notes/images/0.4.0_documentdb-logo.png new file mode 100644 index 000000000..5445c7d47 Binary files /dev/null and b/docs/release-notes/images/0.4.0_documentdb-logo.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs index 59dd815b3..bf8126660 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -52,7 +52,20 @@ export default ts.config( 'no-case-declarations': 'error', 'no-constant-condition': 'error', 'no-inner-declarations': 'error', - 'no-restricted-imports': ['error', { patterns: ['**/*/extension.bundle'] }], + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@microsoft/vscode-azext-utils', + importNames: ['nonNullValue', 'nonNullProp', 'nonNullOrEmptyValue'], + message: + "Do not import nonNull helpers from '@microsoft/vscode-azext-utils'. Use the local 'src/utils/nonNull' instead.", + }, + ], + patterns: ['**/*/extension.bundle'], + }, + ], 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'no-useless-escape': 'error', 'license-header/header': [ diff --git a/extension.bundle.ts b/extension.bundle.ts index 8a13ba230..547308d19 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -19,6 +19,7 @@ export { ObjectId } from 'bson'; // The tests should import '../extension.bundle.ts'. At design-time they live in tests/ and so will pick up this file (extension.bundle.ts). // At runtime the tests live in dist/tests and will therefore pick up the main webpack bundle at dist/extension.bundle.js. export { AzureAccountTreeItemBase, createAzureClient } from '@microsoft/vscode-azext-azureutils'; +// eslint-disable-next-line no-restricted-imports -- bundle intentionally re-exports many helpers for tests; nonNull helpers are provided locally in this repo export * from '@microsoft/vscode-azext-utils'; export { isWindows, wellKnownEmulatorPassword } from './src/constants'; export { connectToClient, isCosmosEmulatorConnectionString } from './src/documentdb/scrapbook/connectToClient'; diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index a9aee7cdf..a55a2f9d0 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -35,6 +35,7 @@ "A connection with the same username and host already exists.": "A connection with the same username and host already exists.", "A new connection will be added to your Connections View.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the exension settings.": "A new connection will be added to your Connections View.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the exension settings.", "A value is required to proceed.": "A value is required to proceed.", + "Account information is incomplete.": "Account information is incomplete.", "Add new document": "Add new document", "Advanced": "Advanced", "All available providers have been added already.": "All available providers have been added already.", @@ -46,19 +47,21 @@ "API: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\"": "API: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\"", "Are you sure?": "Are you sure?", "Attempting to authenticate with \"{cluster}\"…": "Attempting to authenticate with \"{cluster}\"…", - "Attempting to authenticate with {cluster}": "Attempting to authenticate with {cluster}", "Authenticate to connect with your DocumentDB cluster": "Authenticate to connect with your DocumentDB cluster", "Authenticate to connect with your MongoDB cluster": "Authenticate to connect with your MongoDB cluster", "Authenticate using a username and password": "Authenticate using a username and password", "Authenticate using Microsoft Entra ID (Azure AD)": "Authenticate using Microsoft Entra ID (Azure AD)", "Authentication configuration is missing for \"{cluster}\".": "Authentication configuration is missing for \"{cluster}\".", + "Authentication data (primary connection string) is missing for \"{cluster}\".": "Authentication data (primary connection string) is missing for \"{cluster}\".", "Authentication data (properties.connectionString) is missing for \"{cluster}\".": "Authentication data (properties.connectionString) is missing for \"{cluster}\".", "Authentication is required to run this action.": "Authentication is required to run this action.", "Authentication is required to use this migration provider.": "Authentication is required to use this migration provider.", "Azure Activity": "Azure Activity", + "Azure Cosmos DB for MongoDB (RU)": "Azure Cosmos DB for MongoDB (RU)", "Azure Cosmos DB for MongoDB (RU) Emulator": "Azure Cosmos DB for MongoDB (RU) Emulator", "Azure Cosmos DB for MongoDB (vCore)": "Azure Cosmos DB for MongoDB (vCore)", "Azure Service Discovery": "Azure Service Discovery", + "Azure Service Discovery for MongoDB RU": "Azure Service Discovery for MongoDB RU", "Azure VM Service Discovery": "Azure VM Service Discovery", "Azure VM: Attempting to authenticate with \"{vmName}\"…": "Azure VM: Attempting to authenticate with \"{vmName}\"…", "Azure VM: Connected to \"{vmName}\" as \"{username}\".": "Azure VM: Connected to \"{vmName}\" as \"{username}\".", @@ -70,6 +73,7 @@ "Change page size": "Change page size", "Check document syntax": "Check document syntax", "Choose a cluster…": "Choose a cluster…", + "Choose a RU cluster…": "Choose a RU cluster…", "Choose a subscription…": "Choose a subscription…", "Choose a Virtual Machine…": "Choose a Virtual Machine…", "Choose the data migration provider…": "Choose the data migration provider…", @@ -89,17 +93,15 @@ "Configure Azure VM Discovery Filters": "Configure Azure VM Discovery Filters", "Configure TLS/SSL Security": "Configure TLS/SSL Security", "Connect to a database": "Connect to a database", - "Connected to \"{cluster}\" as \"{username}\"": "Connected to \"{cluster}\" as \"{username}\"", - "Connected to \"{cluster}\" as \"{username}\".": "Connected to \"{cluster}\" as \"{username}\".", "Connected to \"{name}\"": "Connected to \"{name}\"", "Connected to the cluster \"{cluster}\".": "Connected to the cluster \"{cluster}\".", "Connecting to the cluster as \"{username}\"…": "Connecting to the cluster as \"{username}\"…", "Connecting to the cluster using Entra ID…": "Connecting to the cluster using Entra ID…", "Connection String": "Connection String", "Connection string is not set": "Connection string is not set", - "Connection string not found.": "Connection string not found.", "Connection updated successfully.": "Connection updated successfully.", - "CosmosDB Accounts": "CosmosDB Accounts", + "Connection: \"{selectedConnectionName}\"\n\nThe connection will be added to the \"Connections View\" in the \"DocumentDB for VS Code\" extension. The \"Connections View\" will be opened once this process completes.\n\nDo you want to continue?": "Connection: \"{selectedConnectionName}\"\n\nThe connection will be added to the \"Connections View\" in the \"DocumentDB for VS Code\" extension. The \"Connections View\" will be opened once this process completes.\n\nDo you want to continue?", + "Connections have moved": "Connections have moved", "Could not find {0}": "Could not find {0}", "Could not find the Azure Resource Groups extension": "Could not find the Azure Resource Groups extension", "Could not find unique name for new file.": "Could not find unique name for new file.", @@ -136,6 +138,7 @@ "Do not save credentials.": "Do not save credentials.", "Document must be an object.": "Document must be an object.", "Document must be an object. Skipping…": "Document must be an object. Skipping…", + "DocumentDB and MongoDB Accounts": "DocumentDB and MongoDB Accounts", "DocumentDB Local": "DocumentDB Local", "Documents": "Documents", "Does this occur consistently? ": "Does this occur consistently? ", @@ -189,6 +192,7 @@ "Exporting documents": "Exporting documents", "Exporting…": "Exporting…", "Extension dependency with id \"{0}\" must be updated.": "Extension dependency with id \"{0}\" must be updated.", + "Failed to access Azure Databases VS Code Extension storage for migration: {error}": "Failed to access Azure Databases VS Code Extension storage for migration: {error}", "Failed to connect to \"{cluster}\"": "Failed to connect to \"{cluster}\"", "Failed to connect to VM \"{vmName}\"": "Failed to connect to VM \"{vmName}\"", "Failed to create Azure management clients: {0}": "Failed to create Azure management clients: {0}", @@ -212,7 +216,6 @@ "Failed to store secrets for key {0}:": "Failed to store secrets for key {0}:", "Failed to update the connection.": "Failed to update the connection.", "Failed with code \"{0}\".": "Failed with code \"{0}\".", - "failed.": "failed.", "Find Query": "Find Query", "Finished importing": "Finished importing", "Go back.": "Go back.", @@ -232,6 +235,7 @@ "Import From JSON…": "Import From JSON…", "Import successful.": "Import successful.", "IMPORTANT: Please be sure to remove any private information before submitting.": "IMPORTANT: Please be sure to remove any private information before submitting.", + "Imported: {name} (imported on {date})": "Imported: {name} (imported on {date})", "Importing document {num} of {countDocuments}": "Importing document {num} of {countDocuments}", "Importing documents…": "Importing documents…", "Importing…": "Importing…", @@ -252,7 +256,6 @@ "Invalid connection type selected.": "Invalid connection type selected.", "Invalid document ID: {0}": "Invalid document ID: {0}", "Invalid semver \"{0}\".": "Invalid semver \"{0}\".", - "Invalid workspace resource ID: {0}": "Invalid workspace resource ID: {0}", "JSON View": "JSON View", "Learn more": "Learn more", "Learn more about {0}.": "Learn more about {0}.", @@ -267,25 +270,24 @@ "Load More...": "Load More...", "Loading \"{0}\"...": "Loading \"{0}\"...", "Loading cluster details for \"{cluster}\"": "Loading cluster details for \"{cluster}\"", + "Loading clusters…": "Loading clusters…", "Loading Content": "Loading Content", "Loading document {num} of {countUri}": "Loading document {num} of {countUri}", "Loading documents…": "Loading documents…", "Loading resources...": "Loading resources...", + "Loading RU clusters…": "Loading RU clusters…", "Loading subscriptions…": "Loading subscriptions…", "Loading Virtual Machines…": "Loading Virtual Machines…", "Loading...": "Loading...", - "Local Emulators": "Local Emulators", "Location": "Location", + "Migration of connections from the Azure Databases VS Code Extension to the DocumentDB for VS Code Extension completed: {migratedCount} connections migrated.": "Migration of connections from the Azure Databases VS Code Extension to the DocumentDB for VS Code Extension completed: {migratedCount} connections migrated.", "Mongo Shell connected.": "Mongo Shell connected.", "Mongo Shell Error: {error}": "Mongo Shell Error: {error}", - "MongoDB Accounts": "MongoDB Accounts", - "MongoDB Cluster Accounts": "MongoDB Cluster Accounts", "MongoDB Emulator": "MongoDB Emulator", "New Connection": "New Connection", "New connection has been added to your DocumentDB Connections.": "New connection has been added to your DocumentDB Connections.", "New connection has been added.": "New connection has been added.", "New Connection…": "New Connection…", - "New Emulator Connection…": "New Emulator Connection…", "New Local Connection": "New Local Connection", "New Local Connection…": "New Local Connection…", "No": "No", @@ -382,6 +384,7 @@ "Successfully created storage account \"{0}\".": "Successfully created storage account \"{0}\".", "Successfully created user assigned identity \"{0}\".": "Successfully created user assigned identity \"{0}\".", "Sure!": "Sure!", + "Switch to the new \"Connections View\"…": "Switch to the new \"Connections View\"…", "Table View": "Table View", "Tag can only contain alphanumeric characters, underscores, periods, and hyphens.": "Tag can only contain alphanumeric characters, underscores, periods, and hyphens.", "Tag cannot be empty.": "Tag cannot be empty.", @@ -437,6 +440,7 @@ "Unable to connect to the local database instance. Make sure it is started correctly. See {link} for tips.": "Unable to connect to the local database instance. Make sure it is started correctly. See {link} for tips.", "Unable to connect to the local instance. Make sure it is started correctly. See {link} for tips.": "Unable to connect to the local instance. Make sure it is started correctly. See {link} for tips.", "Unable to parse syntax near line {line}, col {column}: {message}": "Unable to parse syntax near line {line}, col {column}: {message}", + "Unable to retrieve credentials for cluster \"{cluster}\".": "Unable to retrieve credentials for cluster \"{cluster}\".", "Unable to retrieve credentials for the selected cluster.": "Unable to retrieve credentials for the selected cluster.", "Unexpected status code: {0}": "Unexpected status code: {0}", "Unknown error": "Unknown error", @@ -461,6 +465,7 @@ "Use anyway": "Use anyway", "User is not signed in to Azure.": "User is not signed in to Azure.", "Username and Password": "Username and Password", + "Username cannot be empty": "Username cannot be empty", "Username for {resource}": "Username for {resource}", "Using existing resource group \"{0}\".": "Using existing resource group \"{0}\".", "Using the table navigation, you can explore deeper levels or move back and forth between them.": "Using the table navigation, you can explore deeper levels or move back and forth between them.", diff --git a/package-lock.json b/package-lock.json index ffef3bfc8..b987186f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "vscode-documentdb", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-documentdb", - "version": "0.3.1", + "version": "0.4.0", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@azure/arm-compute": "^22.4.0", - "@azure/arm-cosmosdb": "16.0.0-beta.7", + "@azure/arm-cosmosdb": "16.3.0", "@azure/arm-mongocluster": "1.1.0-beta.1", "@azure/arm-network": "^33.5.0", "@azure/arm-resources": "~6.1.0", @@ -236,35 +236,23 @@ } }, "node_modules/@azure/arm-cosmosdb": { - "version": "16.0.0-beta.7", - "resolved": "https://registry.npmjs.org/@azure/arm-cosmosdb/-/arm-cosmosdb-16.0.0-beta.7.tgz", - "integrity": "sha512-zITOTNZu9Fj8FvCdylN+Lwlei866nVJlYYZDLd+pHltDrgKdNOhyw1xdrs+GYZiKJmURQRwunpW1xPi3UoFg7w==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@azure/arm-cosmosdb/-/arm-cosmosdb-16.3.0.tgz", + "integrity": "sha512-Yix2dqg2jI2nh8Eh69jagvlpjZYVaQ2nm5QuinVTd0NTJdcWAC3Ok8drlDmcJJ2T6MdLE0HsF6ftnADNl2PXMA==", "license": "MIT", "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.6.0", - "@azure/core-client": "^1.7.0", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", "@azure/core-lro": "^2.5.4", - "@azure/core-paging": "^1.2.0", - "@azure/core-rest-pipeline": "^1.14.0", - "tslib": "^2.2.0" + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "tslib": "^2.8.1" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@azure/arm-cosmosdb/node_modules/@azure/abort-controller": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", - "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@azure/arm-mongocluster": { "version": "1.1.0-beta.1", "resolved": "https://registry.npmjs.org/@azure/arm-mongocluster/-/arm-mongocluster-1.1.0-beta.1.tgz", diff --git a/package.json b/package.json index 32a727d80..d53b4dd56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vscode-documentdb", - "version": "0.3.1", + "version": "0.4.0", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "publisher": "ms-azuretools", "displayName": "DocumentDB for VS Code", @@ -145,7 +145,7 @@ }, "dependencies": { "@azure/arm-compute": "^22.4.0", - "@azure/arm-cosmosdb": "16.0.0-beta.7", + "@azure/arm-cosmosdb": "16.3.0", "@azure/arm-mongocluster": "1.1.0-beta.1", "@azure/arm-network": "^33.5.0", "@azure/arm-resources": "~6.1.0", @@ -341,6 +341,13 @@ "title": "Learn More", "icon": "$(info)" }, + { + "//": "[ResourcesView] Save To DocumentDB Connections", + "category": "DocumentDB", + "command": "vscode-documentdb.command.azureResourcesView.addConnectionToConnectionsView", + "title": "Add To Connections In The \"DocumentDB View\"", + "icon": "$(save)" + }, { "//": "Refresh a Tree Item", "category": "DocumentDB", @@ -455,13 +462,11 @@ { "//": "[Database] Scrapbook: New Scrapbook", "command": "vscode-documentdb.command.scrapbook.new", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@1" }, { "//": "[Database] Scrapbook: Connect", "command": "vscode-documentdb.command.scrapbook.connect", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@2" } ], @@ -469,13 +474,11 @@ { "//": "[Collection] Mongo DB|Cluster Scrapbook New", "command": "vscode-documentdb.command.scrapbook.new", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@1" }, { "//": "[Collection] Scrapbook / Connect", "command": "vscode-documentdb.command.scrapbook.connect", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@2" } ], @@ -515,23 +518,23 @@ "view/item/context": [ { "command": "vscode-documentdb.command.connectionsView.updateConnectionString", - "when": "view == connectionsView && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", + "when": "view == connectionsView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "0@2" }, { "command": "vscode-documentdb.command.connectionsView.updateCredentials", - "when": "view == connectionsView && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", + "when": "view == connectionsView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "0@3" }, { "command": "vscode-documentdb.command.connectionsView.renameConnection", - "when": "view == connectionsView && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", + "when": "view == connectionsView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "0@4" }, { "//": "Remove Connection...", "command": "vscode-documentdb.command.connectionsView.removeConnection", - "when": "view == connectionsView && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", + "when": "view == connectionsView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "0@5" }, { @@ -541,12 +544,12 @@ }, { "command": "vscode-documentdb.command.discoveryView.addConnectionToConnectionsView", - "when": "view == discoveryView && viewItem =~ /treeItem.*mongoCluster/i", + "when": "view == discoveryView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "0@1" }, { "command": "vscode-documentdb.command.discoveryView.addConnectionToConnectionsView", - "when": "view == discoveryView && viewItem =~ /treeItem.*mongoCluster/i", + "when": "view == discoveryView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "inline" }, { @@ -569,106 +572,111 @@ "when": "view == discoveryView && viewItem =~ /\\benableFilterCommand\\b/i", "group": "inline" }, + { + "command": "vscode-documentdb.command.azureResourcesView.addConnectionToConnectionsView", + "when": "view =~ /azureResourceGroups|azureFocusView/ && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", + "group": "inline" + }, { "//": "Create database", "command": "vscode-documentdb.command.createDatabase", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.](mongoCluster|account)(?![a-z.\\/])/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "1@1" }, { "//": "Copy connection string", "command": "vscode-documentdb.command.copyConnectionString", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.](mongoCluster|account)(?![a-z.\\/])/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "0@1" }, { "//": "[Account] Mongo DB|Cluster Launch Shell", "command": "vscode-documentdb.command.launchShell", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "2@2" }, { "//": "[Database] Create collection", "command": "vscode-documentdb.command.createCollection", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "1@1" }, { "//": "[Database] Delete database", "command": "vscode-documentdb.command.dropDatabase", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "1@2" }, { "//": "[Database] Mongo DB|Cluster Launch Shell", "command": "vscode-documentdb.command.launchShell", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "2@1" }, { "//": "[Database] Mongo DB|Cluster Scrapbook Submenu", "submenu": "documentDB.submenus.mongo.database.scrapbook", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "2@2" }, { "//": "[Collection] Mongo DB|Cluster Open collection", "command": "vscode-documentdb.command.containerView.open", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "1@1" }, { "//": "[Collection] Create document", "command": "vscode-documentdb.command.createDocument", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "1@2" }, { "//": "[Collection] Import Documents", "command": "vscode-documentdb.command.importDocuments", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "2@1" }, { "//": "[Collection] Export documents", "command": "vscode-documentdb.command.exportDocuments", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "2@2" }, { "//": "[Collection] Data Migration", "command": "vscode-documentdb.command.chooseDataMigrationExtension", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i && migrationProvidersAvailable", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_documentdbcluster\\b/i && migrationProvidersAvailable", "group": "1@2" }, { "//": "[Collection] Drop collection", "command": "vscode-documentdb.command.dropCollection", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "3@1" }, { "//": "[Collection] Launch shell", "command": "vscode-documentdb.command.launchShell", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "4@1" }, { "//": "[Collection] Mongo DB|Cluster Scrapbook Submenu", "submenu": "documentDB.submenus.mongo.collection.scrapbook", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "4@2" }, { "//": "[Collection/Documents] Mongo DB|Cluster Open collection", "command": "vscode-documentdb.command.containerView.open", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]documents(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_documents\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "1@1" }, { - "//": "[TreeItem] Refresh Item (cluster, database, collection, documents, indexes)", + "//": "[TreeItem] Refresh Item (cluster, database, collection, documents, indexes) -> but not in azure(ResourceGroups|FocusView) as it's done there by the Azure Resources host extension.", "command": "vscode-documentdb.command.refresh", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /\\btreeitem[.](mongoCluster|database|collection|documents|indexes|index)\\b/i", + "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /\\btreeitem_(documentdbcluster|database|collection|documents|indexes|index)\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "zheLastGroup@1" }, { @@ -716,6 +724,10 @@ "command": "vscode-documentdb.command.discoveryView.learnMoreAboutProvider", "when": "never" }, + { + "command": "vscode-documentdb.command.azureResourcesView.addConnectionToConnectionsView", + "when": "never" + }, { "command": "vscode-documentdb.command.copyConnectionString", "when": "never" @@ -907,6 +919,15 @@ "image": "resources/walkthroughs/query.png", "altText": "Query editor with autocomplete" } + }, + { + "id": "azure-resources-integration", + "title": "5. Use Azure Resources (optional)", + "description": "**If you use the Azure Resources extension**, you can access Azure Cosmos DB for MongoDB (RU) and Azure Cosmos DB for MongoDB (vCore) clusters from the Azure Resource View. This **optional integration** helps you work from the Azure environment you already use. [Read more...](https://github.com/microsoft/vscode-documentdb/discussions/251)", + "media": { + "image": "resources/walkthroughs/azure-resources.png", + "altText": "Azure Resources integration" + } } ] } diff --git a/resources/documentdb/documentdb_black 1.png b/resources/documentdb/documentdb_black 1.png new file mode 100644 index 000000000..d8d999609 Binary files /dev/null and b/resources/documentdb/documentdb_black 1.png differ diff --git a/resources/documentdb/documentdb_color 1.png b/resources/documentdb/documentdb_color 1.png new file mode 100644 index 000000000..5445c7d47 Binary files /dev/null and b/resources/documentdb/documentdb_color 1.png differ diff --git a/resources/documentdb/documentdb_color_whitetext 3.png b/resources/documentdb/documentdb_color_whitetext 3.png new file mode 100644 index 000000000..725fdce3f Binary files /dev/null and b/resources/documentdb/documentdb_color_whitetext 3.png differ diff --git a/resources/documentdb/documentdb_icon 1.png b/resources/documentdb/documentdb_icon 1.png new file mode 100644 index 000000000..0e012c04a Binary files /dev/null and b/resources/documentdb/documentdb_icon 1.png differ diff --git a/resources/documentdb/documentdb_white 1.png b/resources/documentdb/documentdb_white 1.png new file mode 100644 index 000000000..e570ca6e4 Binary files /dev/null and b/resources/documentdb/documentdb_white 1.png differ diff --git a/resources/icons/vscode-documentdb-icon-blue.svg b/resources/icons/vscode-documentdb-icon-blue.svg deleted file mode 100644 index bb0624171..000000000 --- a/resources/icons/vscode-documentdb-icon-blue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/vscode-documentdb-icon-dark-themes.svg b/resources/icons/vscode-documentdb-icon-dark-themes.svg new file mode 100644 index 000000000..dac8cc32f --- /dev/null +++ b/resources/icons/vscode-documentdb-icon-dark-themes.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/resources/icons/vscode-documentdb-icon-light-themes.svg b/resources/icons/vscode-documentdb-icon-light-themes.svg new file mode 100644 index 000000000..24a9fe8be --- /dev/null +++ b/resources/icons/vscode-documentdb-icon-light-themes.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/resources/icons/vscode-documentdb-icon.svg b/resources/icons/vscode-documentdb-icon.svg deleted file mode 100644 index c2a2a2c5e..000000000 --- a/resources/icons/vscode-documentdb-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/readme/documentdb-logo.png b/resources/readme/documentdb-logo.png new file mode 100644 index 000000000..5445c7d47 Binary files /dev/null and b/resources/readme/documentdb-logo.png differ diff --git a/resources/vscode-documentdb-marketplace-logo.png b/resources/vscode-documentdb-marketplace-logo.png index c265d2a7d..ce5c6fbe2 100644 Binary files a/resources/vscode-documentdb-marketplace-logo.png and b/resources/vscode-documentdb-marketplace-logo.png differ diff --git a/resources/vscode-documentdb-sidebar-icon.svg b/resources/vscode-documentdb-sidebar-icon.svg index 8011c265f..d5a8c7ba6 100644 --- a/resources/vscode-documentdb-sidebar-icon.svg +++ b/resources/vscode-documentdb-sidebar-icon.svg @@ -1 +1,11 @@ - \ No newline at end of file + + + + + + + + + + + diff --git a/resources/walkthroughs/azure-resources.png b/resources/walkthroughs/azure-resources.png new file mode 100644 index 000000000..5df6214c5 Binary files /dev/null and b/resources/walkthroughs/azure-resources.png differ diff --git a/resources/walkthroughs/browse.png b/resources/walkthroughs/browse.png index fc4538277..b2b73310a 100644 Binary files a/resources/walkthroughs/browse.png and b/resources/walkthroughs/browse.png differ diff --git a/resources/walkthroughs/connect.png b/resources/walkthroughs/connect.png index 2c372250f..924a46746 100644 Binary files a/resources/walkthroughs/connect.png and b/resources/walkthroughs/connect.png differ diff --git a/resources/walkthroughs/sidebar.png b/resources/walkthroughs/sidebar.png index 742a37e47..9d73061ae 100644 Binary files a/resources/walkthroughs/sidebar.png and b/resources/walkthroughs/sidebar.png differ diff --git a/src/DocumentDBExperiences.ts b/src/DocumentDBExperiences.ts index 67985a3a6..812b2753a 100644 --- a/src/DocumentDBExperiences.ts +++ b/src/DocumentDBExperiences.ts @@ -4,9 +4,8 @@ *--------------------------------------------------------------------------------------------*/ export enum API { - MongoDB = 'MongoDB', - MongoClusters = 'MongoClusters', - DocumentDB = 'DocumentDB', // This the entry for new API (DocumentDB). + CosmosDBMongoRU = 'mongoRU', + DocumentDB = 'documentDB', } export function getExperienceFromApi(api: API): Experience { @@ -34,6 +33,14 @@ export interface Experience { tag?: string; } +export const CosmosDBMongoRUExperience: Experience = { + api: API.CosmosDBMongoRU, + longName: 'Azure Cosmos DB for MongoDB (RU)', + shortName: 'MongoDB (RU)', + telemetryName: 'mongoru', + tag: 'Azure Cosmos DB for MongoDB (RU)', +} as const; + export const DocumentDBExperience: Experience = { api: API.DocumentDB, longName: 'DocumentDB', @@ -42,22 +49,7 @@ export const DocumentDBExperience: Experience = { tag: 'DocumentDB', } as const; -export const MongoExperience: Experience = { - api: API.MongoDB, - longName: 'Cosmos DB for MongoDB', - shortName: 'MongoDB', - telemetryName: 'mongo', - tag: 'Azure Cosmos DB for MongoDB (RU)', -} as const; - -export const MongoClustersExperience: Experience = { - api: API.MongoClusters, - longName: 'Azure Cosmos DB for MongoDB (vCore)', - shortName: 'MongoDB (vCore)', - telemetryName: 'mongoClusters', -} as const; - -const experiencesArray: Experience[] = [MongoClustersExperience, DocumentDBExperience, MongoExperience]; +const experiencesArray: Experience[] = [DocumentDBExperience, CosmosDBMongoRUExperience]; const experiencesMap = new Map( experiencesArray.map((info: Experience): [API, Experience] => [info.api, info]), ); diff --git a/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts b/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts index 18b51b582..4ac5dd347 100644 --- a/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts +++ b/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts @@ -10,22 +10,45 @@ import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBCon import { Views } from '../../documentdb/Views'; import { API } from '../../DocumentDBExperiences'; import { ext } from '../../extensionVariables'; -import { type DocumentDBResourceItem } from '../../plugins/service-azure/discovery-tree/documentdb/DocumentDBResourceItem'; import { ConnectionStorageService, ConnectionType, type ConnectionItem } from '../../services/connectionStorageService'; import { revealConnectionsViewElement } from '../../tree/api/revealConnectionsViewElement'; import { buildConnectionsViewTreePath, waitForConnectionsViewReady, } from '../../tree/connections-view/connectionsViewHelpers'; +import { type ClusterItemBase } from '../../tree/documentdb/ClusterItemBase'; import { UserFacingError } from '../../utils/commandErrorHandling'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; import { generateDocumentDBStorageId } from '../../utils/storageUtils'; -export async function addConnectionFromRegistry(context: IActionContext, node: DocumentDBResourceItem): Promise { +export async function addConnectionFromRegistry(context: IActionContext, node: ClusterItemBase): Promise { if (!node) { throw new Error(l10n.t('No node selected.')); } + // FYI: As of Sept 2025 this command is used in two views: the discovery view and the azure resources view + const sourceViewId = + node.contextValue.includes('documentDbBranch') || node.contextValue.includes('ruBranch') + ? Views.AzureResourcesView + : Views.DiscoveryView; + + if (sourceViewId === Views.AzureResourcesView) { + // Show a modal dialog informing the user that the details will be saved for future use + const continueButton = l10n.t('Yes, continue'); + const message = l10n.t( + 'Connection: "{selectedConnectionName}"\n\nThe connection will be added to the "Connections View" in the "DocumentDB for VS Code" extension. The "Connections View" will be opened once this process completes.\n\nDo you want to continue?', + { + selectedConnectionName: node.cluster.name, + }, + ); + + const result = await vscode.window.showInformationMessage(message, { modal: true }, continueButton); + + if (result !== continueButton) { + return; // User cancelled + } + } + return vscode.window.withProgress( { location: { viewId: Views.ConnectionsView }, @@ -59,6 +82,10 @@ export async function addConnectionFromRegistry(context: IActionContext, node: D if (existingDuplicateConnection) { // Reveal the existing duplicate connection + await vscode.commands.executeCommand(`connectionsView.focus`); + ext.connectionsBranchDataProvider.refresh(); + await waitForConnectionsViewReady(context); + const connectionPath = buildConnectionsViewTreePath(existingDuplicateConnection.id, false); await revealConnectionsViewElement(context, connectionPath, { select: true, diff --git a/src/commands/chooseDataMigrationExtension/chooseDataMigrationExtension.ts b/src/commands/chooseDataMigrationExtension/chooseDataMigrationExtension.ts index 55be2ca9c..350102d26 100644 --- a/src/commands/chooseDataMigrationExtension/chooseDataMigrationExtension.ts +++ b/src/commands/chooseDataMigrationExtension/chooseDataMigrationExtension.ts @@ -3,13 +3,14 @@ * 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 l10n from '@vscode/l10n'; import { QuickPickItemKind, type QuickPickItem } from 'vscode'; import { CredentialCache } from '../../documentdb/CredentialCache'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; import { MigrationService } from '../../services/migrationServices'; import { type ClusterItemBase } from '../../tree/documentdb/ClusterItemBase'; +import { nonNullValue } from '../../utils/nonNull'; import { openUrl } from '../../utils/openUrl'; export async function chooseDataMigrationExtension(context: IActionContext, node: ClusterItemBase) { @@ -72,7 +73,9 @@ export async function chooseDataMigrationExtension(context: IActionContext, node // } if (migrationProviders.some((provider) => provider.id === selectedItem.id)) { - const selectedProvider = MigrationService.getProvider(nonNullValue(selectedItem.id, 'selectedItem.id')); + const selectedProvider = MigrationService.getProvider( + nonNullValue(selectedItem.id, 'selectedItem.id', 'chooseDataMigrationExtension.ts'), + ); if (selectedProvider) { context.telemetry.properties.migrationProvider = selectedProvider.id; diff --git a/src/commands/createCollection/createCollection.ts b/src/commands/createCollection/createCollection.ts index 030db4a8c..d7e3b9c00 100644 --- a/src/commands/createCollection/createCollection.ts +++ b/src/commands/createCollection/createCollection.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureWizard, nonNullValue, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzureWizard, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import { type DatabaseItem } from '../../tree/documentdb/DatabaseItem'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { nonNullValue } from '../../utils/nonNull'; import { CollectionNameStep } from './CollectionNameStep'; import { type CreateCollectionWizardContext } from './CreateCollectionWizardContext'; import { ExecuteStep } from './ExecuteStep'; @@ -35,7 +36,11 @@ export async function createCollection(context: IActionContext, node: DatabaseIt await wizard.prompt(); await wizard.execute(); - const newCollectionName = nonNullValue(wizardContext.newCollectionName); + const newCollectionName = nonNullValue( + wizardContext.newCollectionName, + 'wizardContext.newCollectionName', + 'createCollection.ts', + ); showConfirmationAsInSettings( l10n.t('The "{newCollectionName}" collection has been created.', { newCollectionName }), ); diff --git a/src/commands/createDatabase/createDatabase.ts b/src/commands/createDatabase/createDatabase.ts index 2f4376053..b369423c5 100644 --- a/src/commands/createDatabase/createDatabase.ts +++ b/src/commands/createDatabase/createDatabase.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureWizard, type IActionContext, nonNullValue } from '@microsoft/vscode-azext-utils'; +import { AzureWizard, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import { CredentialCache } from '../../documentdb/CredentialCache'; import { type ClusterItemBase } from '../../tree/documentdb/ClusterItemBase'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { nonNullValue } from '../../utils/nonNull'; import { type CreateDatabaseWizardContext } from './CreateDatabaseWizardContext'; import { DatabaseNameStep } from './DatabaseNameStep'; import { ExecuteStep } from './ExecuteStep'; @@ -53,6 +54,6 @@ async function createMongoDatabase(context: IActionContext, node: ClusterItemBas await wizard.prompt(); await wizard.execute(); - const newDatabaseName = nonNullValue(wizardContext.databaseName); + const newDatabaseName = nonNullValue(wizardContext.databaseName, 'wizardContext.databaseName', 'createDatabase.ts'); showConfirmationAsInSettings(l10n.t('The "{name}" database has been created.', { name: newDatabaseName })); } diff --git a/src/commands/importDocuments/importDocuments.ts b/src/commands/importDocuments/importDocuments.ts index 79acd0468..2a1e0866c 100644 --- a/src/commands/importDocuments/importDocuments.ts +++ b/src/commands/importDocuments/importDocuments.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { nonNullProp, parseError, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { parseError, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import { EJSON, type Document } from 'bson'; import * as fs from 'node:fs/promises'; @@ -17,6 +17,7 @@ import { import { ext } from '../../extensionVariables'; import { CollectionItem } from '../../tree/documentdb/CollectionItem'; import { BufferErrorCode, createMongoDbBuffer, type DocumentBuffer } from '../../utils/documentBuffer'; +import { nonNullProp } from '../../utils/nonNull'; import { getRootPath } from '../../utils/workspacUtils'; export async function importDocuments( @@ -114,7 +115,14 @@ export async function importDocumentsWithProgress(selectedItem: CollectionItem, let count = 0; let buffer: DocumentBuffer | undefined; if (selectedItem instanceof CollectionItem) { - const hosts = getHostsFromConnectionString(nonNullProp(selectedItem.cluster, 'connectionString')); + const hosts = getHostsFromConnectionString( + nonNullProp( + selectedItem.cluster, + 'connectionString', + 'selectedItem.cluster.connectionString', + 'importDocuments.ts', + ), + ); const isRuResource = hasDomainSuffix(AzureDomains.RU, ...hosts); if (isRuResource) { diff --git a/src/commands/launchShell/launchShell.ts b/src/commands/launchShell/launchShell.ts index c1a3a701c..bfb4ea8e1 100644 --- a/src/commands/launchShell/launchShell.ts +++ b/src/commands/launchShell/launchShell.ts @@ -12,8 +12,8 @@ import { ClustersClient } from '../../documentdb/ClustersClient'; import { maskSensitiveValuesInTelemetry } from '../../documentdb/utils/connectionStringHelpers'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; import { ext } from '../../extensionVariables'; -import { MongoRUResourceItem } from '../../tree/azure-resources-view/documentdb/mongo-ru/MongoRUResourceItem'; -import { MongoVCoreResourceItem } from '../../tree/azure-resources-view/documentdb/mongo-vcore/MongoVCoreResourceItem'; +import { VCoreResourceItem } from '../../tree/azure-resources-view/documentdb/VCoreResourceItem'; +import { RUResourceItem } from '../../tree/azure-resources-view/mongo-ru/RUCoreResourceItem'; import { ClusterItemBase } from '../../tree/documentdb/ClusterItemBase'; import { type CollectionItem } from '../../tree/documentdb/CollectionItem'; import { type DatabaseItem } from '../../tree/documentdb/DatabaseItem'; @@ -195,7 +195,7 @@ export async function launchShell( // Determine if TLS certificate validation should be disabled // This only applies to emulator connections with security disabled - const isRegularCloudAccount = node instanceof MongoVCoreResourceItem || node instanceof MongoRUResourceItem; + const isRegularCloudAccount = node instanceof VCoreResourceItem || node instanceof RUResourceItem; const isEmulatorWithSecurityDisabled = !isRegularCloudAccount && node.cluster.emulatorConfiguration && diff --git a/src/commands/newConnection/PromptConnectionModeStep.ts b/src/commands/newConnection/PromptConnectionModeStep.ts index 70628b604..0a8a059c9 100644 --- a/src/commands/newConnection/PromptConnectionModeStep.ts +++ b/src/commands/newConnection/PromptConnectionModeStep.ts @@ -5,7 +5,7 @@ import { AzureWizardPromptStep, type IWizardOptions } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; -import { MongoClustersExperience } from '../../DocumentDBExperiences'; +import { DocumentDBExperience } from '../../DocumentDBExperiences'; import { ExecuteStep } from './ExecuteStep'; import { ConnectionMode, type NewConnectionWizardContext } from './NewConnectionWizardContext'; import { PromptAuthMethodStep } from './PromptAuthMethodStep'; @@ -40,7 +40,7 @@ export class PromptConnectionModeStep extends AzureWizardPromptStep this.validateInput(context, username), + // eslint-disable-next-line @typescript-eslint/require-await + asyncValidationTask: async (username?: string) => { + if (!username || username.trim().length === 0) { + return l10n.t('Username cannot be empty'); + } + return undefined; + }, }); context.valuesToMask.push(username); diff --git a/src/commands/newLocalConnection/ExecuteStep.ts b/src/commands/newLocalConnection/ExecuteStep.ts index 8d42dbe6b..9616fae1e 100644 --- a/src/commands/newLocalConnection/ExecuteStep.ts +++ b/src/commands/newLocalConnection/ExecuteStep.ts @@ -120,8 +120,7 @@ export class ExecuteStep extends AzureWizardExecuteStep { @@ -17,7 +17,7 @@ export class PromptPortStep extends AzureWizardPromptStep[] = []; const executeSteps: AzureWizardExecuteStep[] = []; - if (node instanceof NewEmulatorConnectionItem || node instanceof NewEmulatorConnectionItemCV) { + if (node instanceof NewEmulatorConnectionItemCV) { title = l10n.t('New Local Connection'); - const api = node instanceof NewEmulatorConnectionItemCV ? API.DocumentDB : API.MongoDB; - steps.push( - new PromptConnectionTypeStep(api), + new PromptConnectionTypeStep(API.DocumentDB), new PromptMongoRUEmulatorConnectionStringStep(), new PromptPortStep(), new PromptUsernameStep(), diff --git a/src/commands/removeConnection/removeConnection.ts b/src/commands/removeConnection/removeConnection.ts index 0166ee991..493187c35 100644 --- a/src/commands/removeConnection/removeConnection.ts +++ b/src/commands/removeConnection/removeConnection.ts @@ -8,18 +8,11 @@ import * as l10n from '@vscode/l10n'; import { CredentialCache } from '../../documentdb/CredentialCache'; import { ext } from '../../extensionVariables'; import { ConnectionStorageService, ConnectionType } from '../../services/connectionStorageService'; -import { StorageNames, StorageService } from '../../services/storageService'; -import { DocumentDBClusterItem } from '../../tree/connections-view/DocumentDBClusterItem'; -import { ClusterItemBase } from '../../tree/documentdb/ClusterItemBase'; -import { WorkspaceResourceType } from '../../tree/workspace-api/SharedWorkspaceResourceProvider'; -import { type ClusterItem } from '../../tree/workspace-view/documentdb/ClusterItem'; +import { type DocumentDBClusterItem } from '../../tree/connections-view/DocumentDBClusterItem'; import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; -export async function removeAzureConnection( - context: IActionContext, - node: ClusterItem | DocumentDBClusterItem, -): Promise { +export async function removeAzureConnection(context: IActionContext, node: DocumentDBClusterItem): Promise { if (!node) { throw new Error(l10n.t('No node selected.')); } @@ -27,10 +20,7 @@ export async function removeAzureConnection( await removeConnection(context, node); } -export async function removeConnection( - context: IActionContext, - node: ClusterItem | DocumentDBClusterItem, -): Promise { +export async function removeConnection(context: IActionContext, node: DocumentDBClusterItem): Promise { context.telemetry.properties.experience = node.experience.api; const confirmed = await getConfirmationAsInSettings( l10n.t('Are you sure?'), @@ -46,29 +36,18 @@ export async function removeConnection( // continue with deletion - if (node instanceof DocumentDBClusterItem) { - await ext.state.showDeleting(node.id, async () => { - if ((node as DocumentDBClusterItem).cluster.emulatorConfiguration?.isEmulator) { - await ConnectionStorageService.delete(ConnectionType.Emulators, node.storageId); - } else { - await ConnectionStorageService.delete(ConnectionType.Clusters, node.storageId); - } - }); + await ext.state.showDeleting(node.id, async () => { + if ((node as DocumentDBClusterItem).cluster.emulatorConfiguration?.isEmulator) { + await ConnectionStorageService.delete(ConnectionType.Emulators, node.storageId); + } else { + await ConnectionStorageService.delete(ConnectionType.Clusters, node.storageId); + } + }); - // delete cached credentials from memory - CredentialCache.deleteCredentials(node.id); + // delete cached credentials from memory + CredentialCache.deleteCredentials(node.id); - ext.connectionsBranchDataProvider.refresh(); - } else if (node instanceof ClusterItemBase) { - await ext.state.showDeleting(node.id, async () => { - await StorageService.get(StorageNames.Workspace).delete( - WorkspaceResourceType.MongoClusters, - node.storageId, - ); - }); - - ext.mongoClustersWorkspaceBranchDataProvider.refresh(); - } + ext.connectionsBranchDataProvider.refresh(); showConfirmationAsInSettings(l10n.t('The selected connection has been removed.')); } diff --git a/src/commands/renameConnection/ExecuteStep.ts b/src/commands/renameConnection/ExecuteStep.ts index 9eec68f72..853b7d580 100644 --- a/src/commands/renameConnection/ExecuteStep.ts +++ b/src/commands/renameConnection/ExecuteStep.ts @@ -17,7 +17,7 @@ export class ExecuteStep extends AzureWizardExecuteStep { + if (!fullContext) { + return false; + } + return new RegExp(`\\b${value}\\b`, 'i').test(fullContext); +}; + export async function retryAuthentication(_context: IActionContext, node: ClusterItemBase): Promise { if (!node) { throw new Error(l10n.t('No node selected.')); } - if (new RegExp(`\\b${Views.ConnectionsView}\\b`, 'i').test(node.contextValue)) { - ext.connectionsBranchDataProvider.resetNodeErrorState(node.id); - return ext.connectionsBranchDataProvider.refresh(node); - } + const contextValue = node.contextValue; + + switch (true) { + case containsDelimited(contextValue, Views.ConnectionsView): + ext.connectionsBranchDataProvider.resetNodeErrorState(node.id); + return ext.connectionsBranchDataProvider.refresh(node); + + case containsDelimited(contextValue, Views.DiscoveryView): + ext.discoveryBranchDataProvider.resetNodeErrorState(node.id); + return ext.discoveryBranchDataProvider.refresh(node); - if (new RegExp(`\\b${Views.DiscoveryView}\\b`, 'i').test(node.contextValue)) { - ext.discoveryBranchDataProvider.resetNodeErrorState(node.id); - return ext.discoveryBranchDataProvider.refresh(node); + case containsDelimited(contextValue, Views.AzureResourcesView): { + if (containsDelimited(contextValue, 'ruBranch')) { + ext.azureResourcesRUBranchDataProvider.resetNodeErrorState(node.id); + return ext.azureResourcesRUBranchDataProvider.refresh(node); + } + if (containsDelimited(contextValue, 'documentDbBranch')) { + ext.azureResourcesVCoreBranchDataProvider.resetNodeErrorState(node.id); + return ext.azureResourcesVCoreBranchDataProvider.refresh(node); + } + break; + } } throw new Error(l10n.t('Unsupported view for an authentication retry.')); diff --git a/src/commands/revealView/revealView.ts b/src/commands/revealView/revealView.ts new file mode 100644 index 000000000..5a1024b78 --- /dev/null +++ b/src/commands/revealView/revealView.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { commands } from 'vscode'; +import { Views } from '../../documentdb/Views'; + +export async function revealView(_context: IActionContext, view: Views): Promise { + switch (view) { + case Views.ConnectionsView: + await commands.executeCommand(`connectionsView.focus`); + break; + case Views.DiscoveryView: + await commands.executeCommand(`discoveryView.focus`); + break; + default: + throw new Error(`Unsupported view: ${view}`); + } +} diff --git a/src/commands/updateConnectionString/ExecuteStep.ts b/src/commands/updateConnectionString/ExecuteStep.ts index 7a0ca8b91..58401862e 100644 --- a/src/commands/updateConnectionString/ExecuteStep.ts +++ b/src/commands/updateConnectionString/ExecuteStep.ts @@ -28,7 +28,11 @@ export class ExecuteStep extends AzureWizardExecuteStep { try { connection.secrets = { ...connection.secrets, - connectionString: nonNullValue(context.newConnectionString?.toString(), 'newConnectionString'), + connectionString: nonNullValue( + context.newConnectionString?.toString(), + 'context.newConnectionString', + 'ExecuteStep.ts', + ), }; await ConnectionStorageService.save(resourceType, connection, true); diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index 167e56026..248b7a2e6 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -15,7 +15,8 @@ import { registerCommand, registerCommandWithTreeNodeUnwrapping, } from '@microsoft/vscode-azext-utils'; -import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { type AzureResourcesExtensionApiWithActivity } from '@microsoft/vscode-azext-utils/activity'; +import { type AzExtResourceType, getAzureResourcesExtensionApi } from '@microsoft/vscode-azureresources-api'; import * as vscode from 'vscode'; import { addConnectionFromRegistry } from '../commands/addConnectionFromRegistry/addConnectionFromRegistry'; import { addDiscoveryRegistry } from '../commands/addDiscoveryRegistry/addDiscoveryRegistry'; @@ -41,22 +42,25 @@ import { removeConnection } from '../commands/removeConnection/removeConnection' import { removeDiscoveryRegistry } from '../commands/removeDiscoveryRegistry/removeDiscoveryRegistry'; import { renameConnection } from '../commands/renameConnection/renameConnection'; import { retryAuthentication } from '../commands/retryAuthentication/retryAuthentication'; +import { revealView } from '../commands/revealView/revealView'; import { updateConnectionString } from '../commands/updateConnectionString/updateConnectionString'; import { updateCredentials } from '../commands/updateCredentials/updateCredentials'; +import { isVCoreAndRURolloutEnabled } from '../extension'; import { ext } from '../extensionVariables'; +import { AzureMongoRUDiscoveryProvider } from '../plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider'; +import { AzureDiscoveryProvider } from '../plugins/service-azure-mongo-vcore/AzureDiscoveryProvider'; import { AzureVMDiscoveryProvider } from '../plugins/service-azure-vm/AzureVMDiscoveryProvider'; -import { AzureDiscoveryProvider } from '../plugins/service-azure/AzureDiscoveryProvider'; import { DiscoveryService } from '../services/discoveryServices'; -import { MongoVCoreBranchDataProvider } from '../tree/azure-resources-view/documentdb/mongo-vcore/MongoVCoreBranchDataProvider'; +import { VCoreBranchDataProvider } from '../tree/azure-resources-view/documentdb/VCoreBranchDataProvider'; +import { RUBranchDataProvider } from '../tree/azure-resources-view/mongo-ru/RUBranchDataProvider'; +import { ClustersWorkspaceBranchDataProvider } from '../tree/azure-workspace-view/ClustersWorkbenchBranchDataProvider'; +import { DocumentDbWorkspaceResourceProvider } from '../tree/azure-workspace-view/DocumentDbWorkspaceResourceProvider'; import { ConnectionsBranchDataProvider } from '../tree/connections-view/ConnectionsBranchDataProvider'; import { DiscoveryBranchDataProvider } from '../tree/discovery-view/DiscoveryBranchDataProvider'; -import { WorkspaceResourceType } from '../tree/workspace-api/SharedWorkspaceResourceProvider'; -import { ClustersWorkspaceBranchDataProvider } from '../tree/workspace-view/documentdb/ClustersWorkbenchBranchDataProvider'; import { registerCommandWithModalErrors, registerCommandWithTreeNodeUnwrappingAndModalErrors, } from '../utils/commandErrorHandling'; -import { enableMongoVCoreSupport, enableWorkspaceSupport } from './activationConditions'; import { registerScrapbookCommands } from './scrapbook/registerScrapbookCommands'; import { Views } from './Views'; @@ -67,6 +71,7 @@ export class ClustersExtension implements vscode.Disposable { registerDiscoveryServices(_activateContext: IActionContext) { DiscoveryService.registerProvider(new AzureDiscoveryProvider()); + DiscoveryService.registerProvider(new AzureMongoRUDiscoveryProvider()); DiscoveryService.registerProvider(new AzureVMDiscoveryProvider()); } @@ -95,39 +100,53 @@ export class ClustersExtension implements vscode.Disposable { ext.context.subscriptions.push(treeView); } - async activateClustersSupport(): Promise { - await callWithTelemetryAndErrorHandling( - 'clustersExtension.activate', - async (activateContext: IActionContext) => { - activateContext.telemetry.properties.isActivationEvent = 'true'; + async registerAzureResourcesIntegration(activateContext: IActionContext): Promise { + // Dynamic registration so this file compiles when the enum members aren't present + // This is how we detect whether the update to Azure Resources has been deployed - // TODO: Implement https://github.com/microsoft/vscode-documentdb/issues/30 - // for staged hand-over from Azure Databases to this DocumentDB extension + const isRolloutEnabled = await isVCoreAndRURolloutEnabled(); + activateContext.telemetry.properties.activatingAzureResourcesIntegration = isRolloutEnabled ? 'true' : 'false'; - // eslint-disable-next-line no-constant-condition, no-constant-binary-expression - if (false && enableMongoVCoreSupport()) { - // on purpose, transition is still in progress - activateContext.telemetry.properties.enabledVCore = 'true'; + if (!isRolloutEnabled) { + return; + } - ext.mongoVCoreBranchDataProvider = new MongoVCoreBranchDataProvider(); - ext.rgApiV2.resources.registerAzureResourceBranchDataProvider( - AzExtResourceType.MongoClusters, - ext.mongoVCoreBranchDataProvider, - ); - } + ext.rgApiV2 = (await getAzureResourcesExtensionApi( + ext.context, + '2.0.0', + )) as AzureResourcesExtensionApiWithActivity; - // eslint-disable-next-line no-constant-condition, no-constant-binary-expression - if (false && enableWorkspaceSupport()) { - // on purpose, transition is still in progress - activateContext.telemetry.properties.enabledWorkspace = 'true'; + const documentDbResourceType = 'AzureDocumentDb' as unknown as AzExtResourceType; + ext.azureResourcesVCoreBranchDataProvider = new VCoreBranchDataProvider(); + ext.rgApiV2.resources.registerAzureResourceBranchDataProvider( + documentDbResourceType, + ext.azureResourcesVCoreBranchDataProvider, + ); - ext.mongoClustersWorkspaceBranchDataProvider = new ClustersWorkspaceBranchDataProvider(); - ext.rgApiV2.resources.registerWorkspaceResourceBranchDataProvider( - WorkspaceResourceType.MongoClusters, - ext.mongoClustersWorkspaceBranchDataProvider, - ); - } + const ruResourceType = 'AzureCosmosDbForMongoDbRu' as unknown as AzExtResourceType; + ext.azureResourcesRUBranchDataProvider = new RUBranchDataProvider(); + ext.rgApiV2.resources.registerAzureResourceBranchDataProvider( + ruResourceType, + ext.azureResourcesRUBranchDataProvider, + ); + + ext.azureResourcesWorkspaceResourceProvider = new DocumentDbWorkspaceResourceProvider(); + ext.rgApiV2.resources.registerWorkspaceResourceProvider(ext.azureResourcesWorkspaceResourceProvider); + ext.azureResourcesWorkspaceBranchDataProvider = new ClustersWorkspaceBranchDataProvider(); + ext.rgApiV2.resources.registerWorkspaceResourceBranchDataProvider( + 'vscode.documentdb.workspace.documentdb-accounts-resourceType', + ext.azureResourcesWorkspaceBranchDataProvider, + ); + } + + async activateClustersSupport(): Promise { + await callWithTelemetryAndErrorHandling( + 'clustersExtension.activate', + async (activateContext: IActionContext) => { + activateContext.telemetry.properties.isActivationEvent = 'true'; + + await this.registerAzureResourcesIntegration(activateContext); this.registerDiscoveryServices(activateContext); this.registerConnectionsTree(activateContext); this.registerDiscoveryTree(activateContext); @@ -196,6 +215,11 @@ export class ClustersExtension implements vscode.Disposable { addConnectionFromRegistry, ); + registerCommandWithTreeNodeUnwrappingAndModalErrors( + 'vscode-documentdb.command.azureResourcesView.addConnectionToConnectionsView', + addConnectionFromRegistry, + ); + registerCommand('vscode-documentdb.command.discoveryView.refresh', (context: IActionContext) => { return refreshView(context, Views.DiscoveryView); }); @@ -232,6 +256,7 @@ export class ClustersExtension implements vscode.Disposable { registerCommand('vscode-documentdb.command.internal.documentView.open', openDocumentView); registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.internal.retry', retryAuthentication); + registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.internal.revealView', revealView); registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.launchShell', launchShell); @@ -265,10 +290,10 @@ export class ClustersExtension implements vscode.Disposable { // but we should log the error for diagnostics try { // Show welcome screen if it hasn't been shown before - const welcomeScreenShown = ext.context.globalState.get('welcomeScreenShown_v0_2_0', false); + const welcomeScreenShown = ext.context.globalState.get('welcomeScreenShown_v0_4_0', false); if (!welcomeScreenShown) { // Update the flag first - await ext.context.globalState.update('welcomeScreenShown_v0_2_0', true); + await ext.context.globalState.update('welcomeScreenShown_v0_4_0', true); ext.outputChannel.appendLog('Showing welcome screen...'); // Schedule the walkthrough to open after activation completes diff --git a/src/documentdb/CredentialCache.ts b/src/documentdb/CredentialCache.ts index 05832cfcd..5a865ca24 100644 --- a/src/documentdb/CredentialCache.ts +++ b/src/documentdb/CredentialCache.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CaseInsensitiveMap } from '../utils/CaseInsensitiveMap'; import { type EmulatorConfiguration } from '../utils/emulatorConfiguration'; import { type AuthMethodId } from './auth/AuthMethod'; import { addAuthenticationDataToConnectionString } from './utils/connectionStringHelpers'; @@ -21,7 +22,8 @@ export interface ClustersCredentials { export class CredentialCache { // the id of the cluster === the tree item id -> cluster credentials - private static _store: Map = new Map(); + // Some SDKs for azure differ the case on some resources ("DocumentDb" vs "DocumentDB") + private static _store: CaseInsensitiveMap = new CaseInsensitiveMap(); public static getConnectionStringWithPassword(mongoClusterId: string): string { return CredentialCache._store.get(mongoClusterId)?.connectionStringWithPassword as string; diff --git a/src/documentdb/Views.ts b/src/documentdb/Views.ts index fe2a5b170..7e95735ec 100644 --- a/src/documentdb/Views.ts +++ b/src/documentdb/Views.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ export enum Views { - ConnectionsView = 'connectionsView', - DiscoveryView = 'discoveryView', + ConnectionsView = 'connectionsView', // do not change this value + DiscoveryView = 'discoveryView', // do not change this value + AzureResourcesView = 'azureResourcesView', + AzureWorkspaceView = 'azureWorkspaceView', /** * Note to future maintainers: do not modify these string constants. diff --git a/src/documentdb/activationConditions.ts b/src/documentdb/activationConditions.ts deleted file mode 100644 index 453d6689d..000000000 --- a/src/documentdb/activationConditions.ts +++ /dev/null @@ -1,85 +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 * as semver from 'semver'; -import { extensions } from 'vscode'; - -/** - * This file contains the activation conditions for the DocumentDB extension. - * The logic has been defined in the following ticket: - * https://github.com/microsoft/vscode-documentdb/issues/30 - * - * The goal is to ensure that parts of the extension are activated in sync with partner extensions. - */ - -const AZURE_DATABASES_WORKSPACE_HANDOVER_VERSION = '10.25.3'; // This is the version that stops supporting documentdb workspaces -const AZURE_DATABASES_VCORE_HANDOVER_VERSION = '10.26.0'; // This is the version that stops supporting vCore Azure Resources -const AZURE_DATABASES_RU_HANDOVER_VERSION = '10.26.0'; // This is the version that stops supporting RU Azure Resources - -let cachedAzureDatabasesVersion: semver.SemVer | null | undefined = undefined; - -/** - * Retrieves and caches the version of the Azure Databases (CosmosDB) extension. - * This is used to determine feature activation based on the partner extension's version. - * Returns a semver.SemVer object if the extension is installed and the version is valid, - * otherwise returns null. - */ -function getAzureDatabasesVersion(): semver.SemVer | null { - if (cachedAzureDatabasesVersion !== undefined) { - return cachedAzureDatabasesVersion; - } - - try { - const vsCodeCosmosDB = extensions.getExtension('ms-azuretools.vscode-cosmosdb'); - if (!vsCodeCosmosDB) { - cachedAzureDatabasesVersion = null; - return cachedAzureDatabasesVersion; - } - const version = (vsCodeCosmosDB.packageJSON as { version: string }).version; - cachedAzureDatabasesVersion = semver.parse(version, true); // Validate the version string - } catch { - cachedAzureDatabasesVersion = null; - } - return cachedAzureDatabasesVersion; -} - -/** - * Determines if workspace support should be enabled in the DocumentDB extension. - * This is based on the version of the Azure Databases extension. - * Returns true if the partner extension's version is greater than or equal to the handover version. - */ -export function enableWorkspaceSupport(): boolean { - const azureDatabasesVersion = getAzureDatabasesVersion(); - if (!azureDatabasesVersion) { - return false; - } - return semver.gte(azureDatabasesVersion, AZURE_DATABASES_WORKSPACE_HANDOVER_VERSION); -} - -/** - * Determines if MongoDB vCore support should be enabled in the DocumentDB extension. - * This is based on the version of the Azure Databases extension. - * Returns true if the partner extension's version is greater than or equal to the handover version. - */ -export function enableMongoVCoreSupport(): boolean { - const azureDatabasesVersion = getAzureDatabasesVersion(); - if (!azureDatabasesVersion) { - return false; - } - return semver.gte(azureDatabasesVersion, AZURE_DATABASES_VCORE_HANDOVER_VERSION); -} - -/** - * Determines if MongoDB RU support should be enabled in the DocumentDB extension. - * This is based on the version of the Azure Databases extension. - * Returns true if the partner extension's version is greater than or equal to the handover version. - */ -export function enableMongoRUSupport(): boolean { - const azureDatabasesVersion = getAzureDatabasesVersion(); - if (!azureDatabasesVersion) { - return false; - } - return semver.gte(azureDatabasesVersion, AZURE_DATABASES_RU_HANDOVER_VERSION); -} diff --git a/src/documentdb/auth/NativeAuthHandler.ts b/src/documentdb/auth/NativeAuthHandler.ts index f5427ad67..c9ae7d457 100644 --- a/src/documentdb/auth/NativeAuthHandler.ts +++ b/src/documentdb/auth/NativeAuthHandler.ts @@ -30,7 +30,8 @@ export class NativeAuthHandler implements AuthHandler { return Promise.resolve({ connectionString: nonNullValue( this.clusterCredentials.connectionStringWithPassword, - 'connectionStringWithPassword', + 'clusterCredentials.connectionStringWithPassword', + 'NativeAuthHandler.ts', ), options, }); diff --git a/src/documentdb/scrapbook/ScrapbookHelpers.ts b/src/documentdb/scrapbook/ScrapbookHelpers.ts index 5846771f0..b2bca937e 100644 --- a/src/documentdb/scrapbook/ScrapbookHelpers.ts +++ b/src/documentdb/scrapbook/ScrapbookHelpers.ts @@ -107,7 +107,7 @@ class FindMongoCommandsVisitor extends MongoVisitor { public visitCommand(ctx: mongoParser.CommandContext): MongoCommand[] { const funcCallCount: number = filterType(ctx.children, mongoParser.FunctionCallContext).length; - const stop = nonNullProp(ctx, 'stop'); + const stop = nonNullProp(ctx, 'stop', 'ctx.stop', 'ScrapbookHelpers.ts'); this.commands.push({ range: new vscode.Range( ctx.start.line - 1, @@ -146,7 +146,7 @@ class FindMongoCommandsVisitor extends MongoVisitor { const argAsObject = this.contextToObject(ctx); const argText = EJSON.stringify(argAsObject); - nonNullProp(lastCommand, 'arguments').push(argText); + nonNullProp(lastCommand, 'arguments', 'lastCommand.arguments', 'ScrapbookHelpers.ts').push(argText); const escapeHandled = this.deduplicateEscapesForRegex(argText); let ejsonParsed = {}; try { @@ -157,7 +157,12 @@ class FindMongoCommandsVisitor extends MongoVisitor { const parsedError: IParsedError = parseError(error); this.addErrorToCommand(parsedError.message, ctx); } - nonNullProp(lastCommand, 'argumentObjects').push(ejsonParsed); + nonNullProp( + lastCommand, + 'argumentObjects', + 'lastCommand.argumentObjects', + 'ScrapbookHelpers.ts', + ).push(ejsonParsed); } } } catch (error) { @@ -178,7 +183,7 @@ class FindMongoCommandsVisitor extends MongoVisitor { return {}; } // In a well formed expression, Argument and propertyValue tokens should have exactly one child, from their definitions in mongo.g4 - const child: ParseTree = nonNullProp(ctx, 'children')[0]; + const child: ParseTree = nonNullProp(ctx, 'children', 'ctx.children', 'ScrapbookHelpers.ts')[0]; if (child instanceof mongoParser.LiteralContext) { return this.literalContextToObject(child, ctx); } else if (child instanceof mongoParser.ObjectLiteralContext) { @@ -235,7 +240,12 @@ class FindMongoCommandsVisitor extends MongoVisitor { mongoParser.PropertyAssignmentContext, ); for (const propertyAssignment of propertyAssignments) { - const propertyAssignmentChildren = nonNullProp(propertyAssignment, 'children'); + const propertyAssignmentChildren = nonNullProp( + propertyAssignment, + 'children', + 'propertyAssignment.children', + 'ScrapbookHelpers.ts', + ); const propertyName = propertyAssignmentChildren[0]; const propertyValue = propertyAssignmentChildren[2]; parsedObject[stripQuotes(propertyName.text)] = this.contextToObject(propertyValue); @@ -261,10 +271,15 @@ class FindMongoCommandsVisitor extends MongoVisitor { // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types ): Object { const functionTokens = child.children; - const constructorCall: TerminalNode = nonNullValue(findType(functionTokens, TerminalNode), 'constructorCall'); + const constructorCall: TerminalNode = nonNullValue( + findType(functionTokens, TerminalNode), + 'constructorCall', + 'ScrapbookHelpers.ts', + ); const argumentsToken: mongoParser.ArgumentsContext = nonNullValue( findType(functionTokens, mongoParser.ArgumentsContext), 'argumentsToken', + 'ScrapbookHelpers.ts', ); if (!(argumentsToken._CLOSED_PARENTHESIS && argumentsToken._OPEN_PARENTHESIS)) { //argumentsToken does not have '(' or ')' @@ -418,7 +433,7 @@ class FindMongoCommandsVisitor extends MongoVisitor { ): void { const command = this.commands[this.commands.length - 1]; command.errors = command.errors || []; - const stop = nonNullProp(ctx, 'stop'); + const stop = nonNullProp(ctx, 'stop', 'ctx.stop', 'ScrapbookHelpers.ts'); const currentErrorDesc: ErrorDescription = { message: errorMessage, range: new vscode.Range( diff --git a/src/documentdb/scrapbook/ShellScriptRunner.ts b/src/documentdb/scrapbook/ShellScriptRunner.ts index 6cef1b48a..61972a432 100644 --- a/src/documentdb/scrapbook/ShellScriptRunner.ts +++ b/src/documentdb/scrapbook/ShellScriptRunner.ts @@ -3,7 +3,7 @@ * 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 { parseError, UserCancelledError, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import * as fs from 'node:fs/promises'; import * as os from 'os'; @@ -14,6 +14,7 @@ import * as cpUtils from '../../utils/cp'; import { type EmulatorConfiguration } from '../../utils/emulatorConfiguration'; import { pathExists } from '../../utils/fs/pathExists'; import { InteractiveChildProcess } from '../../utils/InteractiveChildProcess'; +import { nonNullValue } from '../../utils/nonNull'; import { randomUtils } from '../../utils/randomUtils'; import { getBatchSizeSetting } from '../../utils/workspacUtils'; import { wrapError } from '../../utils/wrapError'; @@ -147,7 +148,13 @@ export class ShellScriptRunner extends vscode.Disposable { } ShellScriptRunner._cachedShellPathOrCmd = shellPath; - const timeout = 1000 * nonNullValue(config.get(ext.settingsKeys.shellTimeout), 'mongoShellTimeout'); + const timeout = + 1000 * + nonNullValue( + config.get(ext.settingsKeys.shellTimeout), + 'config.get(ext.settingsKeys.shellTimeout)', + 'ShellScriptRunner.ts', + ); return ShellScriptRunner.createShellProcessHelper( shellPath, shellArgs, @@ -347,7 +354,6 @@ export class ShellScriptRunner extends vscode.Disposable { 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' : ''] }, diff --git a/src/documentdb/scrapbook/mongoConnectionStrings.ts b/src/documentdb/scrapbook/mongoConnectionStrings.ts index d24d52276..26166f8e0 100644 --- a/src/documentdb/scrapbook/mongoConnectionStrings.ts +++ b/src/documentdb/scrapbook/mongoConnectionStrings.ts @@ -28,7 +28,8 @@ export function getDatabaseNameFromConnectionString(connectionString: string): s try { const [, , databaseName] = nonNullValue( connectionString.match(mongoConnectionStringRegExp), - 'databaseNameMatch', + 'connectionString.match(mongoConnectionStringRegExp)', + 'mongoConnectionStrings.ts', ); return databaseName; } catch { diff --git a/src/extension.ts b/src/extension.ts index aea1dc8d1..6ceec42cc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,13 +7,13 @@ import { registerAzureUtilsExtensionVariables } from '@microsoft/vscode-azext-azureutils'; import { + apiUtils, callWithTelemetryAndErrorHandling, createApiProvider, createAzExtLogOutputChannel, registerErrorHandler, registerUIExtensionVariables, TreeElementStateManager, - type apiUtils, type AzureExtensionApi, type IActionContext, } from '@microsoft/vscode-azext-utils'; @@ -23,6 +23,7 @@ import { ClustersExtension } from './documentdb/ClustersExtension'; import { ext } from './extensionVariables'; import { globalUriHandler } from './vscodeUriHandler'; // Import the DocumentDB Extension API interfaces +import { type AzureResourcesExtensionApi } from '@microsoft/vscode-azureresources-api'; import { type DocumentDBExtensionApi } from '../api/src'; import { MigrationService } from './services/migrationServices'; @@ -106,3 +107,43 @@ export async function activateInternal( export function deactivateInternal(_context: vscode.ExtensionContext): void { // NOOP } + +/** + * Checks if vCore and RU support is to be activated in this extension. + * This introduces changes to the behavior of the extension. + * + * This function is used to determine whether the vCore and RU features should be enabled in this extension. + * + * The result of this function depends on the version of the Azure Resources extension. + * When a new version of the Azure Resources extension is released with the `AzureCosmosDbForMongoDbRu` and `MongoClusters` + * resource types, this function will return true. + * + * @returns True if vCore and RU features are enabled, false | undefined otherwise. + */ +export async function isVCoreAndRURolloutEnabled(): Promise { + return callWithTelemetryAndErrorHandling('isVCoreAndRURolloutEnabled', async (context: IActionContext) => { + // Suppress error display and don't rethrow - this is feature detection that should fail gracefully + context.errorHandling.suppressDisplay = true; + context.errorHandling.rethrow = false; + + const azureResourcesExtensionApi = await apiUtils.getAzureExtensionApi< + AzureResourcesExtensionApi & { isDocumentDbExtensionSupportEnabled: () => boolean } + >(ext.context, 'ms-azuretools.vscode-azureresourcegroups', '3.0.0'); + + // Check if the feature is enabled via the API function + if (typeof azureResourcesExtensionApi.isDocumentDbExtensionSupportEnabled === 'function') { + const isEnabled = azureResourcesExtensionApi.isDocumentDbExtensionSupportEnabled(); + context.telemetry.properties.vCoreAndRURolloutEnabled = String(isEnabled); + context.telemetry.properties.apiMethodAvailable = 'true'; + return isEnabled; + } + + // If the function doesn't exist, assume DISABLED + context.telemetry.properties.vCoreAndRURolloutEnabled = 'false'; + context.telemetry.properties.apiMethodAvailable = 'false'; + ext.outputChannel.appendLog( + 'Expected Azure Resources API v3.0.0 is not available; VCore and RU support remains inactive.', + ); + return false; + }); +} diff --git a/src/extensionVariables.ts b/src/extensionVariables.ts index 0ad211868..67ce815a7 100644 --- a/src/extensionVariables.ts +++ b/src/extensionVariables.ts @@ -8,12 +8,13 @@ import { type AzureResourcesExtensionApiWithActivity } from '@microsoft/vscode-a import type * as vscode from 'vscode'; import { type DatabasesFileSystem } from './DatabasesFileSystem'; import { type MongoDBLanguageClient } from './documentdb/scrapbook/languageClient'; -import { type MongoVCoreBranchDataProvider } from './tree/azure-resources-view/documentdb/mongo-vcore/MongoVCoreBranchDataProvider'; +import { type VCoreBranchDataProvider } from './tree/azure-resources-view/documentdb/VCoreBranchDataProvider'; +import { type RUBranchDataProvider } from './tree/azure-resources-view/mongo-ru/RUBranchDataProvider'; +import { type ClustersWorkspaceBranchDataProvider } from './tree/azure-workspace-view/ClustersWorkbenchBranchDataProvider'; +import { type DocumentDbWorkspaceResourceProvider } from './tree/azure-workspace-view/DocumentDbWorkspaceResourceProvider'; import { type ConnectionsBranchDataProvider } from './tree/connections-view/ConnectionsBranchDataProvider'; import { type DiscoveryBranchDataProvider } from './tree/discovery-view/DiscoveryBranchDataProvider'; import { type TreeElement } from './tree/TreeElement'; -import { type AccountsItem } from './tree/workspace-view/documentdb/AccountsItem'; -import { type ClustersWorkspaceBranchDataProvider } from './tree/workspace-view/documentdb/ClustersWorkbenchBranchDataProvider'; /** * Namespace for common variables used throughout the extension. They must be initialized in the activate() method of extension.ts @@ -33,11 +34,14 @@ export namespace ext { export let state: TreeElementStateManager; - // used for the resources tree - export let mongoVCoreBranchDataProvider: MongoVCoreBranchDataProvider; - // used for the workspace: these are the dedicated providers - export let mongoClustersWorkspaceBranchDataProvider: ClustersWorkspaceBranchDataProvider; - export let mongoClusterWorkspaceBranchDataResource: AccountsItem; + // Azure Resources Extension integration + // > Azure Resources Extension: "Resources View" + export let azureResourcesVCoreBranchDataProvider: VCoreBranchDataProvider; + export let azureResourcesRUBranchDataProvider: RUBranchDataProvider; + + // > Azure Resources Extension: "Workspace View" + export let azureResourcesWorkspaceResourceProvider: DocumentDbWorkspaceResourceProvider; + export let azureResourcesWorkspaceBranchDataProvider: ClustersWorkspaceBranchDataProvider; /** * This is the access point for the connections tree branch data provider. diff --git a/src/plugins/api-shared/azure/wizard/AzureContextProperties.ts b/src/plugins/api-shared/azure/wizard/AzureContextProperties.ts new file mode 100644 index 000000000..6ec698d0f --- /dev/null +++ b/src/plugins/api-shared/azure/wizard/AzureContextProperties.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. + *--------------------------------------------------------------------------------------------*/ + +export enum AzureContextProperties { + AzureSubscriptionProvider = 'azureSubscriptionProvider', + SelectedSubscription = 'selectedSubscription', + SelectedCluster = 'selectedCluster', +} diff --git a/src/plugins/service-azure/discovery-wizard/SelectSubscriptionStep.ts b/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts similarity index 93% rename from src/plugins/service-azure/discovery-wizard/SelectSubscriptionStep.ts rename to src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts index b970f9ec5..54bbe9a9d 100644 --- a/src/plugins/service-azure/discovery-wizard/SelectSubscriptionStep.ts +++ b/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts @@ -7,9 +7,9 @@ import { VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureau import { AzureWizardPromptStep, UserCancelledError } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import { Uri, window, type MessageItem, type QuickPickItem } from 'vscode'; -import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; -import { ext } from '../../../extensionVariables'; -import { AzureContextProperties } from '../AzureDiscoveryProvider'; +import { type NewConnectionWizardContext } from '../../../../commands/newConnection/NewConnectionWizardContext'; +import { ext } from '../../../../extensionVariables'; +import { AzureContextProperties } from './AzureContextProperties'; export class SelectSubscriptionStep extends AzureWizardPromptStep { iconPath = Uri.joinPath( diff --git a/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts b/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts new file mode 100644 index 000000000..34211b5bd --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.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 IActionContext, type IWizardOptions } from '@microsoft/vscode-azext-utils'; +import { Disposable, l10n, ThemeIcon } from 'vscode'; +import { type NewConnectionWizardContext } from '../../commands/newConnection/NewConnectionWizardContext'; +import { ext } from '../../extensionVariables'; +import { type DiscoveryProvider } from '../../services/discoveryServices'; +import { type TreeElement } from '../../tree/TreeElement'; +import { AzureSubscriptionProviderWithFilters } from '../api-shared/azure/AzureSubscriptionProviderWithFilters'; +import { configureAzureSubscriptionFilter } from '../api-shared/azure/subscriptionFiltering'; +import { AzureContextProperties } from '../api-shared/azure/wizard/AzureContextProperties'; +import { SelectSubscriptionStep } from '../service-azure-vm/discovery-wizard/SelectSubscriptionStep'; +import { AzureMongoRUServiceRootItem } from './discovery-tree/AzureMongoRUServiceRootItem'; +import { AzureMongoRUExecuteStep } from './discovery-wizard/AzureMongoRUExecuteStep'; +import { SelectRUClusterStep } from './discovery-wizard/SelectRUClusterStep'; + +export class AzureMongoRUDiscoveryProvider extends Disposable implements DiscoveryProvider { + id = 'azure-mongo-ru-discovery'; + label = l10n.t('Azure Cosmos DB for MongoDB (RU)'); + description = l10n.t('Azure Service Discovery for MongoDB RU'); + iconPath = new ThemeIcon('azure'); + + azureSubscriptionProvider: AzureSubscriptionProviderWithFilters; + + constructor() { + super(() => { + this.azureSubscriptionProvider.dispose(); + }); + + this.azureSubscriptionProvider = new AzureSubscriptionProviderWithFilters(); + } + + getDiscoveryTreeRootItem(parentId: string): TreeElement { + return new AzureMongoRUServiceRootItem(this.azureSubscriptionProvider, parentId); + } + + getDiscoveryWizard(context: NewConnectionWizardContext): IWizardOptions { + context.properties[AzureContextProperties.AzureSubscriptionProvider] = this.azureSubscriptionProvider; + + return { + title: l10n.t('Azure Service Discovery'), + promptSteps: [new SelectSubscriptionStep(), new SelectRUClusterStep()], + executeSteps: [new AzureMongoRUExecuteStep()], + showLoadingPrompt: true, + }; + } + + getLearnMoreUrl(): string | undefined { + return 'https://aka.ms/vscode-documentdb-discovery-providers-azure-ru'; + } + + async configureTreeItemFilter(context: IActionContext, node: TreeElement): Promise { + if (node instanceof AzureMongoRUServiceRootItem) { + await configureAzureSubscriptionFilter(context, this.azureSubscriptionProvider); + ext.discoveryBranchDataProvider.refresh(node); + } + } +} diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts new file mode 100644 index 000000000..5a60b20cf --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { ext } from '../../../extensionVariables'; +import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; +import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; +import { + isTreeElementWithContextValue, + type TreeElementWithContextValue, +} from '../../../tree/TreeElementWithContextValue'; +import { type TreeElementWithRetryChildren } from '../../../tree/TreeElementWithRetryChildren'; +import { AzureMongoRUSubscriptionItem } from './AzureMongoRUSubscriptionItem'; + +export class AzureMongoRUServiceRootItem + implements TreeElement, TreeElementWithContextValue, TreeElementWithRetryChildren +{ + public readonly id: string; + public contextValue: string = 'enableRefreshCommand;enableFilterCommand;enableLearnMoreCommand;azureMongoRUService'; + + constructor( + private readonly azureSubscriptionProvider: VSCodeAzureSubscriptionProvider, + public readonly parentId: string, + ) { + this.id = `${parentId}/azure-mongo-ru-discovery`; + } + + async getChildren(): Promise { + /** + * This is an important step to ensure that the user is signed in to Azure before listing subscriptions. + */ + if (!(await this.azureSubscriptionProvider.isSignedIn())) { + const signIn: vscode.MessageItem = { title: l10n.t('Sign In') }; + void vscode.window + .showInformationMessage(l10n.t('You are not signed in to Azure. Sign in and retry.'), signIn) + .then(async (input) => { + if (input === signIn) { + await this.azureSubscriptionProvider.signIn(); + ext.discoveryBranchDataProvider.refresh(); + } + }); + + return [ + createGenericElementWithContext({ + contextValue: 'error', // note: keep this in sync with the `hasRetryNode` function in this file + id: `${this.id}/retry`, + label: vscode.l10n.t('Click here to retry'), + iconPath: new vscode.ThemeIcon('refresh'), + commandId: 'vscode-documentdb.command.internal.retry', + commandArgs: [this], + }), + ]; + } + + const subscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); + if (!subscriptions || subscriptions.length === 0) { + return []; + } + + return ( + subscriptions + // sort by name + .sort((a, b) => a.name.localeCompare(b.name)) + // map to AzureMongoRUSubscriptionItem + .map((sub) => { + return new AzureMongoRUSubscriptionItem(this.id, { + subscription: sub, + subscriptionName: sub.name, + subscriptionId: sub.subscriptionId, + }); + }) + ); + } + + public hasRetryNode(children: TreeElement[] | null | undefined): boolean { + return ( + children?.some((child) => isTreeElementWithContextValue(child) && child.contextValue === 'error') ?? false + ); + } + + public getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: l10n.t('Azure Cosmos DB for MongoDB (RU)'), + iconPath: new vscode.ThemeIcon('azure'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } +} diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts new file mode 100644 index 000000000..a6cdd22db --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getResourceGroupFromId, uiUtils } from '@microsoft/vscode-azext-azureutils'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as vscode from 'vscode'; +import { CosmosDBMongoRUExperience } from '../../../DocumentDBExperiences'; +import { ext } from '../../../extensionVariables'; +import { type TreeElement } from '../../../tree/TreeElement'; +import { type TreeElementWithContextValue } from '../../../tree/TreeElementWithContextValue'; +import { type ClusterModel } from '../../../tree/documentdb/ClusterModel'; +import { createCosmosDBManagementClient } from '../../../utils/azureClients'; +import { nonNullProp } from '../../../utils/nonNull'; +import { MongoRUResourceItem } from './documentdb/MongoRUResourceItem'; + +export interface AzureSubscriptionModel { + subscriptionName: string; + subscription: AzureSubscription; + subscriptionId: string; +} + +export class AzureMongoRUSubscriptionItem implements TreeElement, TreeElementWithContextValue { + public readonly id: string; + public contextValue: string = 'enableRefreshCommand;azureMongoRUSubscription'; + + constructor( + public readonly parentId: string, + public readonly subscription: AzureSubscriptionModel, + ) { + this.id = `${parentId}/${subscription.subscriptionId}`; + } + + async getChildren(): Promise { + return await callWithTelemetryAndErrorHandling( + 'azure-mongo-ru-discovery.getChildren', + async (context: IActionContext) => { + context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + + const managementClient = await createCosmosDBManagementClient(context, this.subscription.subscription); + const allAccounts = await uiUtils.listAllIterator(managementClient.databaseAccounts.list()); + const accounts = allAccounts.filter((account) => account.kind === 'MongoDB'); + + return accounts + .sort((a, b) => (a.name || '').localeCompare(b.name || '')) + .map((account) => { + const resourceId = nonNullProp(account, 'id', 'account.id', 'AzureMongoRUSubscriptionItem.ts'); + + const clusterInfo: ClusterModel = { + ...account, + resourceGroup: getResourceGroupFromId(resourceId), + dbExperience: CosmosDBMongoRUExperience, + } as ClusterModel; + + return new MongoRUResourceItem(this.subscription.subscription, clusterInfo); + }); + }, + ); + } + + public getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: this.subscription.subscriptionName, + tooltip: `Subscription ID: ${this.subscription.subscriptionId}`, + iconPath: vscode.Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'from_node_modules', + '@microsoft', + 'vscode-azext-azureutils', + 'resources', + 'azureSubscription.svg', + ), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } +} diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts new file mode 100644 index 000000000..828a7047a --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { ClustersClient } from '../../../../documentdb/ClustersClient'; +import { CredentialCache } from '../../../../documentdb/CredentialCache'; +import { Views } from '../../../../documentdb/Views'; +import { ext } from '../../../../extensionVariables'; +import { ClusterItemBase, type ClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; +import { type ClusterModel } from '../../../../tree/documentdb/ClusterModel'; +import { extractCredentialsFromRUAccount } from '../../utils/ruClusterHelpers'; + +export class MongoRUResourceItem extends ClusterItemBase { + iconPath = vscode.Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'from_node_modules', + '@microsoft', + 'vscode-azext-azureutils', + 'resources', + 'azureIcons', + 'MongoClusters.svg', + ); + + constructor( + readonly subscription: AzureSubscription, + cluster: ClusterModel, + ) { + super(cluster); + } + + public async getCredentials(): Promise { + return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { + context.telemetry.properties.view = Views.DiscoveryView; + context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + + const credentials = await extractCredentialsFromRUAccount( + context, + this.subscription, + this.cluster.resourceGroup!, + this.cluster.name, + ); + + return credentials; + }); + } + + /** + * Authenticates and connects to the MongoDB cluster. + * @returns An instance of ClustersClient if successful; otherwise, null. + */ + protected async authenticateAndConnect(): Promise { + const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { + context.telemetry.properties.view = Views.DiscoveryView; + context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + + ext.outputChannel.appendLine( + l10n.t('Attempting to authenticate with "{cluster}"…', { + cluster: this.cluster.name, + }), + ); + + try { + // Get credentials for this cluster + const credentials = await this.getCredentials(); + if (!credentials) { + throw new Error( + l10n.t('Unable to retrieve credentials for cluster "{cluster}".', { + cluster: this.cluster.name, + }), + ); + } + + // Cache the credentials for this cluster + CredentialCache.setAuthCredentials( + this.id, + credentials.selectedAuthMethod ?? credentials.availableAuthMethods[0], + credentials.connectionString, + credentials.connectionUser, + credentials.connectionPassword, + ); + + // Connect using the cached credentials + const clustersClient = await ClustersClient.getClient(this.id); + + ext.outputChannel.appendLine( + l10n.t('Connected to the cluster "{cluster}".', { + cluster: this.cluster.name, + }), + ); + + return clustersClient; + } catch (error) { + ext.outputChannel.appendLine(l10n.t('Error: {error}', { error: (error as Error).message })); + + void vscode.window.showErrorMessage( + l10n.t('Failed to connect to "{cluster}"', { cluster: this.cluster.name }), + { + modal: true, + detail: + l10n.t('Revisit connection details and try again.') + + '\n\n' + + l10n.t('Error: {error}', { error: (error as Error).message }), + }, + ); + + // Clean up failed connection + await ClustersClient.deleteClient(this.id); + CredentialCache.deleteCredentials(this.id); + + return null; + } + }); + + return result ?? null; + } +} diff --git a/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts b/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts new file mode 100644 index 000000000..63c654ec6 --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getResourceGroupFromId } from '@microsoft/vscode-azext-azureutils'; +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; + +import { type GenericResource } from '@azure/arm-resources'; +import { type AzureSubscription } from '@microsoft/vscode-azext-azureauth'; +import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; +import { extractCredentialsFromRUAccount } from '../utils/ruClusterHelpers'; + +export class AzureMongoRUExecuteStep extends AzureWizardExecuteStep { + public priority: number = -1; + + public async execute(context: NewConnectionWizardContext): Promise { + if (context.properties[AzureContextProperties.SelectedSubscription] === undefined) { + throw new Error('SelectedSubscription is not set.'); + } + if (context.properties[AzureContextProperties.SelectedCluster] === undefined) { + throw new Error('SelectedCluster is not set.'); + } + + context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + + const subscription = context.properties[ + AzureContextProperties.SelectedSubscription + ] as unknown as AzureSubscription; + + const cluster = context.properties[AzureContextProperties.SelectedCluster] as unknown as GenericResource; + + const resourceGroup = getResourceGroupFromId(cluster.id!); + + const credentials = await extractCredentialsFromRUAccount(context, subscription, resourceGroup, cluster.name!); + + context.connectionString = credentials.connectionString; + context.username = credentials.connectionUser; + context.password = credentials.connectionPassword; + context.availableAuthenticationMethods = credentials.availableAuthMethods; + + // clean-up + context.properties[AzureContextProperties.SelectedSubscription] = undefined; + context.properties[AzureContextProperties.SelectedCluster] = undefined; + context.properties[AzureContextProperties.AzureSubscriptionProvider] = undefined; + } + + public shouldExecute(): boolean { + return true; + } +} diff --git a/src/plugins/service-azure-mongo-ru/discovery-wizard/SelectRUClusterStep.ts b/src/plugins/service-azure-mongo-ru/discovery-wizard/SelectRUClusterStep.ts new file mode 100644 index 000000000..f161c23cb --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/discovery-wizard/SelectRUClusterStep.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { uiUtils } from '@microsoft/vscode-azext-azureutils'; +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as l10n from '@vscode/l10n'; +import { Uri, type QuickPickItem } from 'vscode'; +import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; +import { ext } from '../../../extensionVariables'; +import { createCosmosDBManagementClient } from '../../../utils/azureClients'; +import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; + +export class SelectRUClusterStep extends AzureWizardPromptStep { + iconPath = Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'from_node_modules', + '@microsoft', + 'vscode-azext-azureutils', + 'resources', + 'azureIcons', + 'MongoClusters.svg', + ); + + public async prompt(context: NewConnectionWizardContext): Promise { + if (context.properties[AzureContextProperties.SelectedSubscription] === undefined) { + throw new Error('SelectedSubscription is not set.'); + } + + const managementClient = await createCosmosDBManagementClient( + context, + context.properties[AzureContextProperties.SelectedSubscription] as unknown as AzureSubscription, + ); + + const allAccounts = await uiUtils.listAllIterator(managementClient.databaseAccounts.list()); + const accounts = allAccounts.filter((account) => account.kind === 'MongoDB'); + + const promptItems: (QuickPickItem & { id: string })[] = accounts + .filter((account) => account.name) // Filter out accounts without a name + .map((account) => ({ + id: account.id!, + label: account.name!, + description: account.id, + iconPath: this.iconPath, + + alwaysShow: true, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selectedItem = await context.ui.showQuickPick([...promptItems], { + stepName: 'selectRUCluster', + placeHolder: l10n.t('Choose a RU cluster…'), + loadingPlaceHolder: l10n.t('Loading RU clusters…'), + enableGrouping: true, + matchOnDescription: true, + suppressPersistence: true, + }); + + context.properties[AzureContextProperties.SelectedCluster] = accounts.find( + (account) => account.id === selectedItem.id, + ); + } + + public shouldPrompt(): boolean { + return true; + } +} diff --git a/src/plugins/service-azure-mongo-ru/utils/ruClusterHelpers.ts b/src/plugins/service-azure-mongo-ru/utils/ruClusterHelpers.ts new file mode 100644 index 000000000..695f0d22d --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/utils/ruClusterHelpers.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * 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 AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as l10n from '@vscode/l10n'; +import { AuthMethodId } from '../../../documentdb/auth/AuthMethod'; +import { maskSensitiveValuesInTelemetry } from '../../../documentdb/utils/connectionStringHelpers'; +import { DocumentDBConnectionString } from '../../../documentdb/utils/DocumentDBConnectionString'; +import { type ClusterCredentials } from '../../../tree/documentdb/ClusterItemBase'; +import { createCosmosDBManagementClient } from '../../../utils/azureClients'; + +/** + * Retrieves cluster information from Azure for RU accounts. + */ +export async function extractCredentialsFromRUAccount( + context: IActionContext, + subscription: AzureSubscription, + resourceGroup: string, + accountName: string, +): Promise { + if (!resourceGroup || !accountName) { + throw new Error(l10n.t('Account information is incomplete.')); + } + + // subscription comes from different azure packages in callers; cast here intentionally + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + const managementClient = await createCosmosDBManagementClient(context, subscription as any); + + const connectionStringsList = await managementClient.databaseAccounts.listConnectionStrings( + resourceGroup, + accountName, + ); + + /** + * databaseAccounts.listConnectionStrings returns an array of (typically 4) connection string objects: + * + * interface DatabaseAccountConnectionString { + * readonly connectionString?: string; + * readonly description?: string; + * readonly keyKind?: Kind; + * readonly type?: Type; + * } + * + * Today we're interested in the one where "keyKind" is "Primary", but this might change in the future. + * Other known values: + * - Primary + * - Secondary + * - PrimaryReadonly + * - SecondaryReadonly + */ + + // More efficient approach + const primaryConnectionString = connectionStringsList?.connectionStrings?.find( + (cs) => cs.keyKind?.toLowerCase() === 'primary', + )?.connectionString; + + // Validate connection string's presence + if (!primaryConnectionString) { + context.telemetry.properties.error = 'missing-connection-string'; + throw new Error( + l10n.t('Authentication data (primary connection string) is missing for "{cluster}".', { + cluster: accountName, + }), + ); + } + + context.valuesToMask.push(primaryConnectionString); + + const parsedCS = new DocumentDBConnectionString(primaryConnectionString); + maskSensitiveValuesInTelemetry(context, parsedCS); + + const username = parsedCS.username; + const password = parsedCS.password; + // do not keep secrets in the connection string + parsedCS.username = ''; + parsedCS.password = ''; + + // the connection string received sometimes contains an 'appName' entry + // with a value that's not escaped, let's just remove it as we don't use + // it here anyway. + parsedCS.searchParams.delete('appName'); + + const clusterCredentials: ClusterCredentials = { + connectionString: parsedCS.toString(), + connectionUser: username, + connectionPassword: password, + availableAuthMethods: [AuthMethodId.NativeAuth], + selectedAuthMethod: AuthMethodId.NativeAuth, + }; + + return clusterCredentials; +} diff --git a/src/plugins/service-azure/AzureDiscoveryProvider.ts b/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts similarity index 90% rename from src/plugins/service-azure/AzureDiscoveryProvider.ts rename to src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts index 73a3b8490..a58633d62 100644 --- a/src/plugins/service-azure/AzureDiscoveryProvider.ts +++ b/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts @@ -11,19 +11,14 @@ import { type DiscoveryProvider } from '../../services/discoveryServices'; import { type TreeElement } from '../../tree/TreeElement'; import { AzureSubscriptionProviderWithFilters } from '../api-shared/azure/AzureSubscriptionProviderWithFilters'; import { configureAzureSubscriptionFilter } from '../api-shared/azure/subscriptionFiltering'; +import { AzureContextProperties } from '../api-shared/azure/wizard/AzureContextProperties'; +import { SelectSubscriptionStep } from '../service-azure-vm/discovery-wizard/SelectSubscriptionStep'; import { AzureServiceRootItem } from './discovery-tree/AzureServiceRootItem'; import { AzureExecuteStep } from './discovery-wizard/AzureExecuteStep'; import { SelectClusterStep } from './discovery-wizard/SelectClusterStep'; -import { SelectSubscriptionStep } from './discovery-wizard/SelectSubscriptionStep'; - -export enum AzureContextProperties { - AzureSubscriptionProvider = 'azureSubscriptionProvider', - SelectedSubscription = 'selectedSubscription', - SelectedCluster = 'selectedCluster', -} export class AzureDiscoveryProvider extends Disposable implements DiscoveryProvider { - id = 'azure-discovery'; + id = 'azure-mongo-vcore-discovery'; label = l10n.t('Azure Cosmos DB for MongoDB (vCore)'); description = l10n.t('Azure Service Discovery'); iconPath = new ThemeIcon('azure'); diff --git a/src/plugins/service-azure/discovery-tree/AzureServiceRootItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts similarity index 98% rename from src/plugins/service-azure/discovery-tree/AzureServiceRootItem.ts rename to src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts index 223bc79f5..c150280cd 100644 --- a/src/plugins/service-azure/discovery-tree/AzureServiceRootItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts @@ -25,7 +25,7 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext private readonly azureSubscriptionProvider: VSCodeAzureSubscriptionProvider, public readonly parentId: string, ) { - this.id = `${parentId}/azure-discovery`; + this.id = `${parentId}/azure-mongo-vcore-discovery`; } async getChildren(): Promise { diff --git a/src/plugins/service-azure/discovery-tree/AzureSubscriptionItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts similarity index 90% rename from src/plugins/service-azure/discovery-tree/AzureSubscriptionItem.ts rename to src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts index 0ca266d5e..e6687fc4b 100644 --- a/src/plugins/service-azure/discovery-tree/AzureSubscriptionItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts @@ -4,15 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { getResourceGroupFromId, uiUtils } from '@microsoft/vscode-azext-azureutils'; -import { callWithTelemetryAndErrorHandling, nonNullProp, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; import * as vscode from 'vscode'; -import { MongoClustersExperience } from '../../../DocumentDBExperiences'; +import { DocumentDBExperience } from '../../../DocumentDBExperiences'; import { ext } from '../../../extensionVariables'; import { type TreeElement } from '../../../tree/TreeElement'; import { type TreeElementWithContextValue } from '../../../tree/TreeElementWithContextValue'; import { type ClusterModel } from '../../../tree/documentdb/ClusterModel'; import { createResourceManagementClient } from '../../../utils/azureClients'; +import { nonNullProp } from '../../../utils/nonNull'; import { DocumentDBResourceItem } from './documentdb/DocumentDBResourceItem'; export interface AzureSubscriptionModel { @@ -45,12 +46,12 @@ export class AzureSubscriptionItem implements TreeElement, TreeElementWithContex return accounts .sort((a, b) => (a.name || '').localeCompare(b.name || '')) .map((account) => { - const resourceId = nonNullProp(account, 'id'); + const resourceId = nonNullProp(account, 'id', 'account.id', 'AzureSubscriptionItem.ts'); const clusterInfo: ClusterModel = { ...account, resourceGroup: getResourceGroupFromId(resourceId), - dbExperience: MongoClustersExperience, + dbExperience: DocumentDBExperience, } as ClusterModel; return new DocumentDBResourceItem(this.subscription.subscription, clusterInfo); diff --git a/src/plugins/service-azure/discovery-tree/documentdb/DocumentDBResourceItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts similarity index 94% rename from src/plugins/service-azure/discovery-tree/documentdb/DocumentDBResourceItem.ts rename to src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts index 1e2509cd8..8f33604b5 100644 --- a/src/plugins/service-azure/discovery-tree/documentdb/DocumentDBResourceItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts @@ -7,7 +7,6 @@ import { type MongoCluster } from '@azure/arm-mongocluster'; import { AzureWizard, callWithTelemetryAndErrorHandling, - nonNullValue, UserCancelledError, type IActionContext, } from '@microsoft/vscode-azext-utils'; @@ -25,6 +24,7 @@ import { ProvideUserNameStep } from '../../../../documentdb/wizards/authenticate import { ext } from '../../../../extensionVariables'; import { ClusterItemBase, type ClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; import { type ClusterModel } from '../../../../tree/documentdb/ClusterModel'; +import { nonNullValue } from '../../../../utils/nonNull'; import { extractCredentialsFromCluster, getClusterInformationFromAzure } from '../../utils/clusterHelpers'; export class DocumentDBResourceItem extends ClusterItemBase { @@ -49,7 +49,7 @@ export class DocumentDBResourceItem extends ClusterItemBase { public async getCredentials(): Promise { return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-discovery'; + context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; // Retrieve and validate cluster information (throws if invalid) const clusterInformation = await getClusterInformationFromAzure( @@ -83,7 +83,7 @@ export class DocumentDBResourceItem extends ClusterItemBase { protected async authenticateAndConnect(): Promise { const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-discovery'; + context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; ext.outputChannel.appendLine( l10n.t('Attempting to authenticate with "{cluster}"…', { @@ -116,8 +116,12 @@ export class DocumentDBResourceItem extends ClusterItemBase { // Cache credentials and attempt connection CredentialCache.setAuthCredentials( this.id, - nonNullValue(wizardContext.selectedAuthMethod, 'authMethod'), - nonNullValue(credentials.connectionString), + nonNullValue( + wizardContext.selectedAuthMethod, + 'wizardContext.selectedAuthMethod', + 'DocumentDBResourceItem.ts', + ), + nonNullValue(credentials.connectionString, 'credentials.connectionString', 'DocumentDBResourceItem.ts'), wizardContext.selectedUserName, wizardContext.password, ); @@ -185,7 +189,7 @@ export class DocumentDBResourceItem extends ClusterItemBase { // Prompt the user for credentials await callWithTelemetryAndErrorHandling('connect.promptForCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-discovery'; + context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; context.errorHandling.rethrow = true; context.errorHandling.suppressDisplay = false; diff --git a/src/plugins/service-azure/discovery-wizard/AzureExecuteStep.ts b/src/plugins/service-azure-mongo-vcore/discovery-wizard/AzureExecuteStep.ts similarity index 96% rename from src/plugins/service-azure/discovery-wizard/AzureExecuteStep.ts rename to src/plugins/service-azure-mongo-vcore/discovery-wizard/AzureExecuteStep.ts index 484a89f27..798df5ca4 100644 --- a/src/plugins/service-azure/discovery-wizard/AzureExecuteStep.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-wizard/AzureExecuteStep.ts @@ -9,7 +9,7 @@ import { type NewConnectionWizardContext } from '../../../commands/newConnection import { type GenericResource } from '@azure/arm-resources'; import { type AzureSubscription } from '@microsoft/vscode-azext-azureauth'; import { getResourceGroupFromId } from '@microsoft/vscode-azext-azureutils'; -import { AzureContextProperties } from '../AzureDiscoveryProvider'; +import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; import { extractCredentialsFromCluster, getClusterInformationFromAzure } from '../utils/clusterHelpers'; export class AzureExecuteStep extends AzureWizardExecuteStep { diff --git a/src/plugins/service-azure/discovery-wizard/SelectClusterStep.ts b/src/plugins/service-azure-mongo-vcore/discovery-wizard/SelectClusterStep.ts similarity index 92% rename from src/plugins/service-azure/discovery-wizard/SelectClusterStep.ts rename to src/plugins/service-azure-mongo-vcore/discovery-wizard/SelectClusterStep.ts index 9dd3e95e1..bc0d0b736 100644 --- a/src/plugins/service-azure/discovery-wizard/SelectClusterStep.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-wizard/SelectClusterStep.ts @@ -11,7 +11,7 @@ import { Uri, type QuickPickItem } from 'vscode'; import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; import { ext } from '../../../extensionVariables'; import { createResourceManagementClient } from '../../../utils/azureClients'; -import { AzureContextProperties } from '../AzureDiscoveryProvider'; +import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; export class SelectClusterStep extends AzureWizardPromptStep { iconPath = Uri.joinPath( @@ -43,6 +43,7 @@ export class SelectClusterStep extends AzureWizardPromptStep account.name) // Filter out accounts without a name .map((account) => ({ id: account.id!, label: account.name!, @@ -56,7 +57,7 @@ export class SelectClusterStep extends AzureWizardPromptStep { iconPath = Uri.joinPath( diff --git a/src/services/connectionStorageService.ts b/src/services/connectionStorageService.ts index 19960b226..96a87a2a8 100644 --- a/src/services/connectionStorageService.ts +++ b/src/services/connectionStorageService.ts @@ -3,11 +3,29 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { apiUtils, callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; import { AuthMethodId } from '../documentdb/auth/AuthMethod'; import { DocumentDBConnectionString } from '../documentdb/utils/DocumentDBConnectionString'; import { API } from '../DocumentDBExperiences'; +import { isVCoreAndRURolloutEnabled } from '../extension'; +import { ext } from '../extensionVariables'; import { StorageNames, StorageService, type Storage, type StorageItem } from './storageService'; +/** + * API for migrating MongoDB cluster connections from Azure Databases extension + */ +interface MongoConnectionMigrationApi { + apiVersion: string; + exportMongoClusterConnections(context: vscode.ExtensionContext): Promise; + renameMongoClusterConnectionStorageId( + context: vscode.ExtensionContext, + oldId: string, + newId: string, + ): Promise; +} + export enum ConnectionType { Clusters = 'clusters', Emulators = 'emulators', @@ -73,20 +91,45 @@ const enum SecretIndex { * underlying storage and migration complexity. */ export class ConnectionStorageService { + private static readonly MIGRATION_FROM_AZUREDATABASES_ATTEMPTS_KEY = + 'ConnectionStorageService.migrationAttemptsFromAzureDatabases'; + // Lazily-initialized underlying storage instance. We must not call StorageService.get // at module-load time because `ext.context` may not be available until the extension // is activated. Create the Storage on first access instead. private static _storageService: Storage | undefined; - private static get storageService(): Storage { + private static async getStorageService(): Promise { if (!this._storageService) { this._storageService = StorageService.get(StorageNames.Connections); + + if (await isVCoreAndRURolloutEnabled()) { + try { + // Trigger migration on first access, but only if we haven't reached the attempt limit + const migrationAttempts = ext.context.globalState.get( + this.MIGRATION_FROM_AZUREDATABASES_ATTEMPTS_KEY, + 0, + ); + + if (migrationAttempts < 20) { + // this is a good number as any, just keep trying for a while to account for failures + await this.migrateFromAzureDatabases(); + } + } catch (error) { + // Migration is optional - output error for debugging but don't break storage service initialization + console.debug( + 'Optional migration check failed:', + error instanceof Error ? error.message : String(error), + ); + } + } } return this._storageService; } public static async getAll(connectionType: ConnectionType): Promise { - const items = await this.storageService.getItems(connectionType); + const storageService = await this.getStorageService(); + const items = await storageService.getItems(connectionType); return items.map((item) => this.fromStorageItem(item)); } @@ -94,16 +137,19 @@ export class ConnectionStorageService { * Returns a single connection by id, or undefined if not found. */ public static async get(connectionId: string, connectionType: ConnectionType): Promise { - const storageItem = await this.storageService.getItem(connectionType, connectionId); + const storageService = await this.getStorageService(); + const storageItem = await storageService.getItem(connectionType, connectionId); return storageItem ? this.fromStorageItem(storageItem) : undefined; } public static async save(connectionType: ConnectionType, item: ConnectionItem, overwrite?: boolean): Promise { - await this.storageService.push(connectionType, this.toStorageItem(item), overwrite); + const storageService = await this.getStorageService(); + await storageService.push(connectionType, this.toStorageItem(item), overwrite); } public static async delete(connectionType: ConnectionType, itemId: string): Promise { - await this.storageService.delete(connectionType, itemId); + const storageService = await this.getStorageService(); + await storageService.delete(connectionType, itemId); } private static toStorageItem(item: ConnectionItem): StorageItem { @@ -186,4 +232,181 @@ export class ConnectionStorageService { }, }; } + + /** + * Gets the MongoDB Migration API from the Azure Databases extension + */ + private static async getMongoMigrationApi(): Promise { + try { + const cosmosDbExtension = vscode.extensions.getExtension('ms-azuretools.vscode-cosmosdb'); + if (!cosmosDbExtension) { + console.debug('getMongoMigrationApi: ms-azuretools.vscode-cosmosdb is not installed.'); + return undefined; + } + + const api = await apiUtils.getAzureExtensionApi( + ext.context, + 'ms-azuretools.vscode-cosmosdb', + '2.0.0', + ); + + if ( + !api || + typeof api.exportMongoClusterConnections !== 'function' || + typeof api.renameMongoClusterConnectionStorageId !== 'function' + ) { + console.debug('getMongoMigrationApi: Requested API version is not available.'); + return undefined; + } + + return api; + } catch (error) { + console.debug( + `getMongoMigrationApi: Error accessing MongoDB Migration API: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } + } + + /** + * Migrates connections from Azure Databases extension storage to DocumentDB extension storage. + * This function is called automatically on first storage access to ensure one-time migration. + * + * @returns Promise resolving to migration statistics + */ + private static async migrateFromAzureDatabases(): Promise<{ migrated: number; skipped: number }> { + const result = await callWithTelemetryAndErrorHandling( + 'migrateFromAzureDatabases', + async (context: IActionContext) => { + // Increment migration attempt counter at the start of each attempt + const currentAttempts = ext.context.globalState.get( + this.MIGRATION_FROM_AZUREDATABASES_ATTEMPTS_KEY, + 0, + ); + await ext.context.globalState.update( + this.MIGRATION_FROM_AZUREDATABASES_ATTEMPTS_KEY, + currentAttempts + 1, + ); + context.telemetry.measurements.migrationAttemptNumber = currentAttempts + 1; + + const MIGRATION_PREFIX = 'migrated-to-vscode-documentdb-'; + let migratedCount = 0; + let skippedCount = 0; + const startTime = Date.now(); + + try { + const mongoMigrationApi = await this.getMongoMigrationApi(); + + if (!mongoMigrationApi) { + context.telemetry.properties.migrationAttempted = 'false'; + context.telemetry.properties.reason = 'api_not_available'; + return { migrated: 0, skipped: 0 }; + } + + // Use the API to get MongoDB connections - cast to local StorageItem[] + const allLegacyItems = (await mongoMigrationApi.exportMongoClusterConnections(ext.context)) as + | StorageItem[] + | undefined; + + if (!allLegacyItems || allLegacyItems.length === 0) { + context.telemetry.properties.migrationAttempted = 'true'; + context.telemetry.properties.hasOldStorage = 'false'; + return { migrated: 0, skipped: 0 }; + } + + context.telemetry.properties.migrationAttempted = 'true'; + context.telemetry.properties.hasOldStorage = 'true'; + context.telemetry.measurements.itemsReadFromLegacyStorage = allLegacyItems.length; + + const currentDate = new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + for (const legacyItem of allLegacyItems) { + if (legacyItem.id.startsWith(MIGRATION_PREFIX)) { + skippedCount++; + continue; + } + + try { + // Migrate the item using existing migrateToV2 logic + const migratedItem = this.migrateToV2(legacyItem); + + migratedItem.name = l10n.t('Imported: {name} (imported on {date})', { + name: migratedItem.name, + date: currentDate, + }); + + // Determine connection type based on emulator flag + const connectionType = legacyItem.properties?.isEmulator + ? ConnectionType.Emulators + : ConnectionType.Clusters; + + // Save to new storage + await this.save(connectionType, migratedItem, true); + + // Use the API to rename the connection ID in the legacy storage + const newId = `${MIGRATION_PREFIX}${legacyItem.id}`; + const renameSuccess = await mongoMigrationApi.renameMongoClusterConnectionStorageId( + ext.context, + legacyItem.id, + newId, + ); + + if (renameSuccess) { + migratedCount++; + } else { + ext.outputChannel.appendLog( + `Failed to rename connection in Azure Databases extension: ${legacyItem.id}`, + ); + skippedCount++; + } + } catch (error) { + // Log individual item migration errors but continue with others + ext.outputChannel.appendLog( + `Failed to migrate from Azure Databases VS Code Extension: connection item ${legacyItem.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + skippedCount++; + } + } + + // Set success telemetry + context.telemetry.properties.migrationSuccessful = 'true'; + context.telemetry.measurements.migrationDurationMs = Date.now() - startTime; + context.telemetry.measurements.itemsMigrated = migratedCount; + context.telemetry.measurements.itemsSkipped = skippedCount; + + if (migratedCount > 0) { + ext.outputChannel.appendLog( + l10n.t( + 'Migration of connections from the Azure Databases VS Code Extension to the DocumentDB for VS Code Extension completed: {migratedCount} connections migrated.', + { migratedCount }, + ), + ); + } + } catch (error) { + // Set failure telemetry + context.telemetry.properties.migrationSuccessful = 'false'; + context.telemetry.properties.errorType = + error instanceof Error ? error.constructor.name : 'UnknownError'; + context.telemetry.measurements.migrationDurationMs = Date.now() - startTime; + context.telemetry.measurements.itemsMigrated = migratedCount; + context.telemetry.measurements.itemsSkipped = skippedCount; + + // Log errors but don't throw + ext.outputChannel.appendLog( + l10n.t('Failed to access Azure Databases VS Code Extension storage for migration: {error}', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } + + return { migrated: migratedCount, skipped: skippedCount }; + }, + ); + + return result ?? { migrated: 0, skipped: 0 }; + } } diff --git a/src/tree/BaseExtendedTreeDataProvider.ts b/src/tree/BaseExtendedTreeDataProvider.ts new file mode 100644 index 000000000..5d5cac0b8 --- /dev/null +++ b/src/tree/BaseExtendedTreeDataProvider.ts @@ -0,0 +1,505 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { ext } from '../extensionVariables'; +import { dispose } from '../utils/vscodeUtils'; +import { type ExtendedTreeDataProvider } from './ExtendedTreeDataProvider'; +import { type TreeElement } from './TreeElement'; +import { isTreeElementWithContextValue, type TreeElementWithContextValue } from './TreeElementWithContextValue'; +import { isTreeElementWithRetryChildren } from './TreeElementWithRetryChildren'; +import { TreeParentCache } from './TreeParentCache'; + +/** + * Base implementation of the ExtendedTreeDataProvider interface that provides + * parent-child relationship caching, error handling, and state management. + * + * ## Key Features + * + * 1. **Tree Navigation** + * - Efficient parent-child relationship tracking for TreeView.reveal() functionality + * - Node lookup by ID for programmatic navigation + * - Refresh handling that maintains proper object identity + * + * 2. **Error Management** + * - Automatic caching of failed operations to prevent repeated connection attempts + * - Recovery mechanisms with helper action nodes + * - Granular error state reset capabilities + * + * 3. **State Processing** + * - Automatic context value propagation for UI integration + * - Consistent state handling wrapper application + * - Parent-child relationship registration + * + * ## Implementation Guide + * + * When extending this class, implementers should: + * + * 1. **Implement getChildren()** + * ```typescript + * async getChildren(element?: TreeElement): Promise { + * return callWithTelemetryAndErrorHandling('getChildren', async (context: IActionContext) => { + * // Handle root elements specially + * if (!element) { + * // Clear the parent cache when refreshing at root level + * this.clearParentCache(); + * // Initialize root items + * return rootItems; + * } + * + * // For child elements, use the helper method + * return this.wrapGetChildrenWithErrorAndStateHandling( + * element, + * context, + * async () => element.getChildren?.(), + * { contextValue: 'yourViewName' } // Single string or string[] supported + * ); + * }); + * } + * ``` + * + * 2. **Use refresh() for tree updates** + * ```typescript + * // Refresh a specific node + * this.refresh(element); + * + * // Refresh the entire tree + * this.refresh(); + * ``` + * + * 3. **Reset error states when needed** + * ```typescript + * // Clear error state for a specific node + * this.resetNodeErrorState(nodeId); + * ``` + * + * 4. **Use cache management helpers** + * ```typescript + * // Clear the parent cache (typically at root level) + * this.clearParentCache(); + * + * // Register a node in the cache + * this.registerNodeInCache(node); + * + * // Register a parent-child relationship + * this.registerRelationshipInCache(parentNode, childNode); + * ``` + * + * The primary pattern is to use `wrapGetChildrenWithErrorAndStateHandling()` which provides + * a complete workflow for fetching and processing tree children, including error handling, + * parent-child relationship registration, and state management. + * + * @template T The tree element type that extends TreeElement + */ +export abstract class BaseExtendedTreeDataProvider + extends vscode.Disposable + implements ExtendedTreeDataProvider +{ + /** + * Cache for tracking parent-child relationships to support the getParent method. + * + * This cache enables: + * - Efficient implementation of tree.reveal() functionality to navigate to specific nodes + * - Finding nodes by ID without traversing the entire tree each time + * - Proper cleanup when refreshing parts of the tree + * + * Note: Do not access this cache directly. Use the provided helper methods: + * - clearParentCache(): Clear the entire cache + * - registerNodeInCache(): Register a node in the cache + * - registerRelationshipInCache(): Register a parent-child relationship + */ + private readonly parentCache = new TreeParentCache(); + + /** + * Caches the full set of children for nodes that failed to load properly. + * + * This cache prevents repeated attempts to fetch children for nodes that have previously failed, + * such as when a user enters invalid credentials. By storing the failed children, we avoid unnecessary + * repeated calls until the error state is explicitly cleared. + * + * Key: Node ID (parent) + * Value: Array of TreeElement representing the failed children (usually includes an error node) + */ + protected readonly failedChildrenCache = new Map(); + + /** + * Event emitter for notifying VS Code when tree data changes. + * + * From vscode.TreeDataProvider: + * An optional event to signal that an element or root has changed. + * This will trigger the view to update the changed element/root and its children recursively (if shown). + * To signal that root has changed, do not pass any argument or pass `undefined` or `null`. + */ + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + + /** + * Collection of disposable resources that should be cleaned up when this provider is disposed. + * Derived classes can add their own disposables to this array. + */ + protected readonly disposables: vscode.Disposable[] = []; + + /** + * Event fired when tree data changes. Required by vscode.TreeDataProvider. + */ + get onDidChangeTreeData(): vscode.Event { + return this.onDidChangeTreeDataEmitter.event; + } + + constructor() { + super(() => this.dispose()); + } + + /** + * Clears the parent-child relationship cache. + * + * This should be called when refreshing at the root level to ensure clean state + * and prevent stale relationships from affecting tree navigation. + */ + protected clearParentCache(): void { + this.parentCache.clear(); + } + + /** + * Registers a node in the parent cache. + * + * @param node The node to register + */ + protected registerNodeInCache(node: T): void { + if (node.id) { + this.parentCache.registerNode(node); + } + } + + /** + * Registers a parent-child relationship in the cache. + * + * @param parent The parent node + * @param child The child node + */ + protected registerRelationshipInCache(parent: T, child: T): void { + if (parent.id && child.id) { + this.parentCache.registerRelationship(parent, child); + } + } + + /** + * Gets the parent of a tree element. Required for TreeView.reveal functionality. + * + * @param element The element for which to find the parent + * @returns The parent element, or undefined if the element is a root item + */ + getParent(element: T): T | null | undefined { + return this.parentCache.getParent(element); + } + + /** + * Gets the tree item representation for VS Code. Required by vscode.TreeDataProvider. + * + * Note: Due to caching done by the TreeElementStateManager, + * changes to the TreeItem added here might get lost. + * + * @param element The tree element to convert to a tree item + * @returns Promise resolving to the tree item representation + */ + async getTreeItem(element: T): Promise { + return element.getTreeItem(); + } + + /** + * Removes a node's error state from the failed children cache. + * This allows the node to be refreshed and its children to be re-fetched on the next refresh call. + * If not reset, the cached error children will always be returned for this node. + * + * @param nodeId The ID of the node to clear from the failed children cache. + */ + resetNodeErrorState(nodeId: string): void { + this.failedChildrenCache.delete(nodeId); + } + + /** + * Finds a node in the tree by its ID. + * + * Note: By default, this method only searches in the nodes that are known, i.e., nodes that have been processed + * by the data provider. Hidden nodes, such as those that haven't been expanded and their children, + * will not be discovered. However, if `enableRecursiveSearch` is set to `true`, the method will perform + * a more intensive search by automatically expanding nodes as needed. This can be time-consuming for + * large trees or deeply nested structures. + * + * @param id The ID of the node to find + * @param enableRecursiveSearch Optional boolean to enable a deeper search with automatic node expansion + * @returns A Promise that resolves to the found node or undefined if not found + */ + async findNodeById(id: string, enableRecursiveSearch?: boolean): Promise { + if (enableRecursiveSearch) { + // Pass this.getChildren as the second parameter to enable recursive search + return this.parentCache.findNodeById( + id, + this.getChildren.bind(this) as (element: T) => Promise, + ); + } else { + // If recursive search is not enabled, we only search in the known nodes + return this.parentCache.findNodeById(id); + } + } + + /** + * Refreshes the tree data. + * This will trigger the view to update the changed element/root and its children recursively (if shown). + * + * @param element The element to refresh. If not provided, the entire tree will be refreshed. + * + * Note: This implementation handles both current and stale element references. + * If a stale reference is provided but has an ID, it will attempt to find the current + * reference in the tree before refreshing. + */ + refresh(element?: T): void { + if (element?.id) { + // We have an element with an ID + + // Handle potential stale reference issue: + // VS Code's TreeView API relies on object identity (reference equality), + // not just ID equality. Find the current reference before clearing the cache. + void this.findAndRefreshCurrentElement(element); + } else { + // No element or no ID, refresh the entire tree + this.clearParentCache(); + this.onDidChangeTreeDataEmitter.fire(element); + } + } + + /** + * Helper method to find the current instance of an element by ID and refresh it. + * This addresses the issue where stale references won't properly refresh the tree. + * + * @param element Potentially stale element reference + */ + protected async findAndRefreshCurrentElement(element: T): Promise { + try { + // First try to find the current instance with this ID + const currentElement = await this.findNodeById(element.id!); + + // AFTER finding the element, update the cache: + // 1. Clear the cache for this ID to remove any stale references + // (drops the element and its children) + this.parentCache.clear(element.id!); + // 2. Re-register the node (but not its children) + if (currentElement?.id) { + this.registerNodeInCache(currentElement); + } + + if (currentElement) { + // We found the current instance, use it for refresh + this.onDidChangeTreeDataEmitter.fire(currentElement); + } else { + // Current instance not found, fallback to using the provided element + // This may not work if it's truly a stale reference, but we've tried our best + this.onDidChangeTreeDataEmitter.fire(element); + } + } catch (error) { + // If anything goes wrong during the lookup, still attempt the refresh with the original element + // and clear the cache for this ID + console.log(`Error finding current element for refresh: ${error}`); + this.parentCache.clear(element.id!); + this.onDidChangeTreeDataEmitter.fire(element); + } + } + + /** + * Helper method for appending context values to tree items. + * + * This method provides a consistent way for derived classes to add context values + * to tree elements, ensuring proper formatting and preservation of existing values. + * + * @param treeItem The tree item to modify + * @param contextValuesToAppend The context values to append + */ + protected appendContextValues(treeItem: TreeElementWithContextValue, ...contextValuesToAppend: string[]): void { + const contextValues: string[] = contextValuesToAppend; + + // Keep original contextValues if any + if (treeItem.contextValue) { + contextValues.push(treeItem.contextValue); + } + + treeItem.contextValue = createContextValue(contextValues); + } + + /** + * Wraps element's getChildren call with error caching, child processing, and state handling. + * + * This method provides a complete workflow for fetching and processing tree children: + * 1. Error state caching to prevent repeated failures + * 2. Child fetching with proper error detection + * 3. Helper node creation for error recovery + * 4. Context value appending for UI features + * 5. Parent-child relationship registration for navigation + * 6. State handling wrapping for proper updates + * + * @param element The tree element to get children for + * @param context The action context for telemetry + * @param childrenFetchFunc Function to call to fetch children + * @param options Configuration options for error handling and child processing + * @returns Processed children array, or null/undefined if none + * + * @example + * // Basic usage with single context value: + * const children = await this.wrapGetChildrenWithErrorAndStateHandling( + * element, + * context, + * async () => element.getChildren?.(), + * { contextValue: Views.ConnectionsView } + * ); + * return children; // Already fully processed + * + * @example + * // Using an array of context values: + * const children = await this.wrapGetChildrenWithErrorAndStateHandling( + * element, + * context, + * async () => element.getChildren?.(), + * { contextValue: [Views.AzureResourcesView, 'vCoreBranch'] } + * ); + * return children; // Already fully processed with multiple context values + * + * @example + * // With helper nodes (Connections provider): + * const children = await this.wrapGetChildrenWithErrorAndStateHandling( + * element, + * context, + * async () => element.getChildren?.(), + * { + * contextValue: [Views.ConnectionsView, 'anotherValue'], + * createHelperNodes: (el) => [ + * createGenericElementWithContext({ + * contextValue: 'error', + * id: `${el.id}/updateCredentials`, + * label: vscode.l10n.t('Click here to update credentials'), + * iconPath: new vscode.ThemeIcon('key'), + * commandId: 'vscode-documentdb.command.connectionsView.updateCredentials', + * commandArgs: [el], + * }) as TreeElement, + * ] + * } + * ); + * return children; // Already fully processed + */ + protected async wrapGetChildrenWithErrorAndStateHandling( + element: T, + context: IActionContext, + childrenFetchFunc: () => Promise, + options: { + detectErrorState?: (element: T, children: T[] | null | undefined) => boolean; + createHelperNodes?: (element: T) => T[]; + contextValue?: string | string[]; // For automatic context value appending - single or multiple values + } = {}, + ): Promise { + // 1. Check if we have cached error children for this element + // + // This prevents repeated attempts to fetch children for nodes that have previously failed + // (e.g., due to invalid credentials or connection issues). + if (element.id && this.failedChildrenCache.has(element.id)) { + context.telemetry.properties.usedCachedErrorNode = 'true'; + return this.failedChildrenCache.get(element.id); + } + + // 2. Fetch the children of the current element + const children = await childrenFetchFunc(); + context.telemetry.measurements.childrenCount = children?.length ?? 0; + + // 3. Check if the returned children contain an error node + // This means the operation failed (e.g., authentication) + const hasError = options.detectErrorState + ? options.detectErrorState(element, children) + : isTreeElementWithRetryChildren(element) && element.hasRetryNode(children); + + if (hasError && element.id) { + // 4. Optionally create helper nodes to provide user-friendly error recovery actions + if (options.createHelperNodes) { + const helperNodes = options.createHelperNodes(element); + children?.push(...helperNodes); + } + + // 5. Store the complete error state (error nodes + helper nodes) in our cache for future refreshes + this.failedChildrenCache.set(element.id, children ?? []); + context.telemetry.properties.cachedErrorNode = 'true'; + } + + // 6. Process children when contextValue is provided (automatic child processing) + if (options.contextValue && children) { + return children.map((child) => { + if (child.id) { + if (isTreeElementWithContextValue(child)) { + const contextValues = Array.isArray(options.contextValue) + ? options.contextValue + : options.contextValue !== undefined + ? [options.contextValue] + : []; + this.appendContextValues(child, ...contextValues); + } + + // Register parent-child relationship in the cache + if (element.id && child.id) { + this.registerRelationshipInCache(element, child); + } + + return ext.state.wrapItemInStateHandling(child, () => this.refresh(child)) as T; + } + return child; + }); + } + + return children; + } + + /** + * Wraps element's getChildren call with error caching to prevent repeated failures. + * + * This method standardizes the error handling pattern used across all tree data providers, + * implementing a consistent approach to: + * + * 1. **Error State Caching**: Checks for cached error states to prevent repeated failed attempts + * 2. **Children Fetching**: Calls the provided function to fetch children from the element + * 3. **Error Detection**: Uses configurable logic to detect when children contain error states + * 4. **Helper Node Creation**: Optionally creates helper action nodes for user-friendly error recovery + * 5. **Telemetry Tracking**: Sets telemetry properties to track caching behavior and error states + * + * @deprecated Use wrapGetChildrenWithErrorAndStateHandling instead for enhanced functionality + * @param element The tree element to get children for + * @param context The action context for telemetry + * @param childrenFetchFunc Function to call to fetch children - typically () => element.getChildren?.() + * @param options Configuration options for error detection and helper nodes + * @returns Promise resolving to the children array, including any error nodes and helper nodes + */ + protected async wrapGetChildrenWithErrorHandling( + element: T, + context: IActionContext, + childrenFetchFunc: () => Promise, + options: { + detectErrorState?: (element: T, children: T[] | null | undefined) => boolean; + createHelperNodes?: (element: T) => T[]; + } = {}, + ): Promise { + // Delegate to the enhanced method without child processing + return this.wrapGetChildrenWithErrorAndStateHandling(element, context, childrenFetchFunc, options); + } + + /** + * Disposes of all resources held by this provider. + * This includes the event emitter and any disposables registered by derived classes. + */ + dispose(): void { + this.onDidChangeTreeDataEmitter.dispose(); + dispose(this.disposables); + } + + /** + * Abstract method that must be implemented by derived classes to provide the actual tree structure. + * + * @param element The parent element for which to get children, or undefined for root elements + * @returns Promise resolving to an array of child elements, null, or undefined + */ + abstract getChildren(element?: T): Promise; +} diff --git a/src/tree/BaseCachedBranchDataProvider.ts b/src/tree/RemoveMeBaseCachedBranchDataProvider.ts similarity index 99% rename from src/tree/BaseCachedBranchDataProvider.ts rename to src/tree/RemoveMeBaseCachedBranchDataProvider.ts index 78e268c6c..74397dc66 100644 --- a/src/tree/BaseCachedBranchDataProvider.ts +++ b/src/tree/RemoveMeBaseCachedBranchDataProvider.ts @@ -48,7 +48,7 @@ import { isTreeElementWithExperience } from './TreeElementWithExperience'; * @extends vscode.Disposable * @implements {BranchDataProvider} */ -export abstract class BaseCachedBranchDataProvider +export abstract class RemoveMeBaseCachedBranchDataProvider extends vscode.Disposable implements BranchDataProvider { @@ -365,7 +365,7 @@ export abstract class BaseCachedBranchDataProvider + implements BranchDataProvider +{ + /** + * Helper for managing lazy metadata loading with proper caching and item updates. + * This replaces the manual cache management that was previously done with + * detailsCacheUpdateRequested, detailsCache, and itemsToUpdateInfo properties. + */ + private readonly metadataLoader = new LazyMetadataLoader({ + cacheDuration: 5 * 60 * 1000, // 5 minutes + loadMetadata: async (subscription, context) => { + console.debug( + 'Loading metadata cache for %s/%s', + context.telemetry.properties.view, + context.telemetry.properties.branch, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + const managementClient = await createMongoClustersManagementClient(context, subscription as any); + const accounts = await uiUtils.listAllIterator(managementClient.mongoClusters.list()); + + console.debug( + 'Loaded metadata for %s/%s: %d entries', + context.telemetry.properties.view, + context.telemetry.properties.branch, + accounts.length, + ); + + const cache = new CaseInsensitiveMap(); + accounts.forEach((documentDbAccount) => { + cache.set(nonNullProp(documentDbAccount, 'id', 'vCoreAccount.id', 'VCoreBranchDataProvider.ts'), { + dbExperience: DocumentDBExperience, + id: documentDbAccount.id!, + name: documentDbAccount.name!, + resourceGroup: getResourceGroupFromId(documentDbAccount.id!), + location: documentDbAccount.location, + serverVersion: documentDbAccount.properties?.serverVersion, + systemData: { + createdAt: documentDbAccount.systemData?.createdAt, + }, + sku: documentDbAccount.properties?.compute?.tier, + diskSize: documentDbAccount.properties?.storage?.sizeGb, + nodeCount: documentDbAccount.properties?.sharding?.shardCount, + enableHa: documentDbAccount.properties?.highAvailability?.targetMode !== 'Disabled', + }); + }); + return cache; + }, + updateItem: (item, metadata) => { + if (metadata) { + item.cluster = { ...item.cluster, ...metadata }; + } + }, + refreshCallback: (item) => this.refresh(item), + }); + + constructor() { + super(); + this.disposables.push(this.metadataLoader); + } + + async getChildren(element: TreeElement): Promise { + return await callWithTelemetryAndErrorHandling('getChildren', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureResourcesView; + context.telemetry.properties.branch = 'documentdb'; + + context.telemetry.properties.parentNodeContext = (await element.getTreeItem()).contextValue; + + // Use the enhanced method with the contextValue parameter + const children = await this.wrapGetChildrenWithErrorAndStateHandling( + element, + context, + async () => element.getChildren?.(), + { + contextValue: ['documentDbBranch', Views.AzureResourcesView], // This enables automatic child processing + }, + ); + + // Return the processed children directly - no additional processing needed + return children; + }); + } + + getResourceItem(resource: AzureResource): TreeElement | Thenable { + return callWithTelemetryAndErrorHandling('getResourceItem', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureResourcesView; + context.telemetry.properties.branch = 'documentdb'; + + // Trigger cache loading if needed + if (this.metadataLoader.needsCacheUpdate) { + void this.metadataLoader.loadCacheAndRefreshItems(resource.subscription, context); + } + + // Get metadata from cache (may be undefined if not yet loaded) + const cachedMetadata = this.metadataLoader.getCachedMetadata(resource.id); + + let clusterInfo: ClusterModel = { + ...resource, + dbExperience: DocumentDBExperience, + } as ClusterModel; + + // Merge with cached metadata if available + if (cachedMetadata) { + clusterInfo = { ...clusterInfo, ...cachedMetadata }; + } + + const clusterItem = new VCoreResourceItem(resource.subscription, clusterInfo); + ext.state.wrapItemInStateHandling(clusterItem, () => this.refresh(clusterItem)); + + if (isTreeElementWithContextValue(clusterItem)) { + this.appendContextValues(clusterItem, 'documentDbBranch', Views.AzureResourcesView); + } + + // Register item for refresh when cache loading completes + this.metadataLoader.addItemForRefresh(resource.id, clusterItem); + + return clusterItem; + }) as TreeElement | Thenable; // Cast to ensure correct type; + } +} diff --git a/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts b/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts new file mode 100644 index 000000000..6e61c6117 --- /dev/null +++ b/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts @@ -0,0 +1,211 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type MongoCluster } from '@azure/arm-mongocluster'; +import { + AzureWizard, + callWithTelemetryAndErrorHandling, + UserCancelledError, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { AuthMethodId } from '../../../documentdb/auth/AuthMethod'; +import { ClustersClient } from '../../../documentdb/ClustersClient'; +import { CredentialCache } from '../../../documentdb/CredentialCache'; +import { Views } from '../../../documentdb/Views'; +import { type AuthenticateWizardContext } from '../../../documentdb/wizards/authenticate/AuthenticateWizardContext'; +import { ChooseAuthMethodStep } from '../../../documentdb/wizards/authenticate/ChooseAuthMethodStep'; +import { ProvidePasswordStep } from '../../../documentdb/wizards/authenticate/ProvidePasswordStep'; +import { ProvideUserNameStep } from '../../../documentdb/wizards/authenticate/ProvideUsernameStep'; +import { ext } from '../../../extensionVariables'; +import { + extractCredentialsFromCluster, + getClusterInformationFromAzure, +} from '../../../plugins/service-azure-mongo-vcore/utils/clusterHelpers'; +import { nonNullValue } from '../../../utils/nonNull'; +import { ClusterItemBase, type ClusterCredentials } from '../../documentdb/ClusterItemBase'; +import { type ClusterModel } from '../../documentdb/ClusterModel'; + +export class VCoreResourceItem extends ClusterItemBase { + iconPath = vscode.Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'from_node_modules', + '@microsoft', + 'vscode-azext-azureutils', + 'resources', + 'azureIcons', + 'MongoClusters.svg', + ); + + constructor( + readonly subscription: AzureSubscription, + cluster: ClusterModel, + ) { + super(cluster); + } + + public async getCredentials(): Promise { + return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureResourcesView; + context.telemetry.properties.branch = 'documentdb'; + + // Retrieve and validate cluster information (throws if invalid) + const clusterInformation = await getClusterInformationFromAzure( + context, + this.subscription, + this.cluster.resourceGroup!, + this.cluster.name, + ); + + return extractCredentialsFromCluster(context, clusterInformation); + }); + } + + /** + * Retrieves and validates cluster information from Azure. + */ + private async getClusterInformation(context: IActionContext): Promise { + return getClusterInformationFromAzure( + context, + this.subscription, + this.cluster.resourceGroup!, + this.cluster.name, + ); + } + + /** + * Authenticates and connects to the MongoDB cluster. + * @param context The action context. + * @returns An instance of ClustersClient if successful; otherwise, null. + */ + protected async authenticateAndConnect(): Promise { + const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureResourcesView; + context.telemetry.properties.branch = 'documentdb'; + + ext.outputChannel.appendLine( + l10n.t('Attempting to authenticate with "{cluster}"…', { + cluster: this.cluster.name, + }), + ); + + // Get and validate cluster information + const clusterInformation = await this.getClusterInformation(context); + const credentials = extractCredentialsFromCluster(context, clusterInformation); + + // Prepare wizard context + const wizardContext: AuthenticateWizardContext = { + ...context, + adminUserName: credentials.connectionUser, + resourceName: this.cluster.name, + availableAuthMethods: credentials.availableAuthMethods, + }; + + // Prompt for credentials + const credentialsProvided = await this.promptForCredentials(wizardContext); + if (!credentialsProvided) { + return null; + } + + if (wizardContext.password) { + context.valuesToMask.push(wizardContext.password); + } + + // Cache credentials and attempt connection + CredentialCache.setAuthCredentials( + this.id, + nonNullValue( + wizardContext.selectedAuthMethod, + 'wizardContext.selectedAuthMethod', + 'VCoreResourceItem.ts', + ), + nonNullValue(credentials.connectionString, 'credentials.connectionString', 'VCoreResourceItem.ts'), + wizardContext.selectedUserName, + wizardContext.password, + ); + + switch (wizardContext.selectedAuthMethod) { + case AuthMethodId.MicrosoftEntraID: + ext.outputChannel.append(l10n.t('Connecting to the cluster using Entra ID…')); + break; + default: + ext.outputChannel.append( + l10n.t('Connecting to the cluster as "{username}"…', { + username: wizardContext.selectedUserName ?? '', + }), + ); + } + + try { + const clustersClient = await ClustersClient.getClient(this.id); + + ext.outputChannel.appendLine( + l10n.t('Connected to the cluster "{cluster}".', { + cluster: this.cluster.name, + }), + ); + + return clustersClient; + } catch (error) { + ext.outputChannel.appendLine(l10n.t('Error: {error}', { error: (error as Error).message })); + + void vscode.window.showErrorMessage( + l10n.t('Failed to connect to "{cluster}"', { cluster: this.cluster.name }), + { + modal: true, + detail: + l10n.t('Revisit connection details and try again.') + + '\n\n' + + l10n.t('Error: {error}', { error: (error as Error).message }), + }, + ); + + // Clean up failed connection + await ClustersClient.deleteClient(this.id); + CredentialCache.deleteCredentials(this.id); + + return null; + } + }); + + return result ?? null; + } + + /** + * Prompts the user for credentials using a wizard. + * + * @param wizardContext The wizard context. + * @returns True if the wizard completed successfully; false if the user canceled or an error occurred. + */ + private async promptForCredentials(wizardContext: AuthenticateWizardContext): Promise { + const wizard = new AzureWizard(wizardContext, { + promptSteps: [new ChooseAuthMethodStep(), new ProvideUserNameStep(), new ProvidePasswordStep()], + title: l10n.t('Authenticate to connect with your DocumentDB cluster'), + showLoadingPrompt: true, + }); + + // Prompt the user for credentials + await callWithTelemetryAndErrorHandling('connect.promptForCredentials', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureResourcesView; + context.telemetry.properties.branch = 'documentdb'; + + context.errorHandling.rethrow = true; + context.errorHandling.suppressDisplay = false; + try { + await wizard.prompt(); // This will prompt the user; results are stored in wizardContext + } catch (error) { + if (error instanceof UserCancelledError) { + wizardContext.aborted = true; + } + } + }); + + // Return true if the wizard completed successfully; false otherwise + return !wizardContext.aborted; + } +} diff --git a/src/tree/azure-resources-view/documentdb/mongo-ru/MongoRUResourceItem.ts b/src/tree/azure-resources-view/documentdb/mongo-ru/MongoRUResourceItem.ts deleted file mode 100644 index cf2abb420..000000000 --- a/src/tree/azure-resources-view/documentdb/mongo-ru/MongoRUResourceItem.ts +++ /dev/null @@ -1,128 +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 { callWithTelemetryAndErrorHandling, nonNullProp, type IActionContext } from '@microsoft/vscode-azext-utils'; -import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; -import * as l10n from '@vscode/l10n'; -import * as vscode from 'vscode'; -import { ClustersClient } from '../../../../documentdb/ClustersClient'; -import { CredentialCache } from '../../../../documentdb/CredentialCache'; -import { DocumentDBConnectionString } from '../../../../documentdb/utils/DocumentDBConnectionString'; -import { ext } from '../../../../extensionVariables'; -import { createCosmosDBManagementClient } from '../../../../utils/azureClients'; -import { ClusterItemBase, type ClusterCredentials } from '../../../documentdb/ClusterItemBase'; -import { type ClusterModel } from '../../../documentdb/ClusterModel'; - -export class MongoRUResourceItem extends ClusterItemBase { - public getCredentials(): Promise { - throw new Error('Method not implemented.'); - } - constructor( - readonly subscription: AzureSubscription, - mongoCluster: ClusterModel, - ) { - super(mongoCluster); - } - - public async getConnectionString(): Promise { - return callWithTelemetryAndErrorHandling( - 'documentDB.mongoClusters.getConnectionString', - 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.subscription as AzureSubscription, - ); - const connectionStringsInfo = await managementClient.databaseAccounts.listConnectionStrings( - this.cluster.resourceGroup as string, - this.cluster.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; - }, - ); - } - - /** - * Authenticates and connects to the MongoDB cluster. - * @param context The action context. - * @returns An instance of ClustersClient if successful; otherwise, null. - */ - protected async authenticateAndConnect(): Promise { - const result = await callWithTelemetryAndErrorHandling( - 'documentDB.mongoClusters.connect', - async (context: IActionContext) => { - ext.outputChannel.appendLine( - l10n.t('Attempting to authenticate with "{cluster}"…', { - cluster: this.cluster.name, - }), - ); - - if (this.subscription) { - this.cluster.connectionString = await this.getConnectionString(); - } - - if (!this.cluster.connectionString) { - throw new Error(l10n.t('Connection string not found.')); - } - - context.valuesToMask.push(this.cluster.connectionString); - - const cString = new DocumentDBConnectionString(this.cluster.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); - - const mongoClient = await ClustersClient.getClient(this.id).catch(async (error) => { - console.error(error); - // If connection fails, remove cached credentials, as they might be invalid - await ClustersClient.deleteClient(this.id); - CredentialCache.deleteCredentials(this.id); - return null; - }); - - return mongoClient; - }, - ); - - return result ?? null; - } - - /** - * Returns the tree item representation of the cluster. - * @returns The TreeItem object. - */ - getTreeItem(): vscode.TreeItem { - return { - id: this.id, - contextValue: this.contextValue, - label: this.cluster.name, - description: `(${this.experience.shortName})`, - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - }; - } -} diff --git a/src/tree/azure-resources-view/documentdb/mongo-vcore/MongoVCoreBranchDataProvider.ts b/src/tree/azure-resources-view/documentdb/mongo-vcore/MongoVCoreBranchDataProvider.ts deleted file mode 100644 index a10e91b2e..000000000 --- a/src/tree/azure-resources-view/documentdb/mongo-vcore/MongoVCoreBranchDataProvider.ts +++ /dev/null @@ -1,127 +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 GenericResource } from '@azure/arm-resources'; -import { getResourceGroupFromId, uiUtils } from '@microsoft/vscode-azext-azureutils'; -import { callWithTelemetryAndErrorHandling, nonNullProp, type IActionContext } from '@microsoft/vscode-azext-utils'; -import { type AzureResource, type AzureSubscription } from '@microsoft/vscode-azureresources-api'; -import { API, MongoClustersExperience } from '../../../../DocumentDBExperiences'; -import { createMongoClustersManagementClient } from '../../../../utils/azureClients'; -import { BaseCachedBranchDataProvider } from '../../../BaseCachedBranchDataProvider'; -import { type ClusterModel } from '../../../documentdb/ClusterModel'; -import { type TreeElement } from '../../../TreeElement'; -import { MongoVCoreResourceItem } from './MongoVCoreResourceItem'; - -export type MongoVCoreResource = AzureResource & - GenericResource & { - readonly raw: GenericResource; // Resource object from Azure SDK - }; - -export class MongoVCoreBranchDataProvider extends BaseCachedBranchDataProvider { - protected get contextValue(): string { - return 'mongoVCore.azure'; - } - - private detailsCacheUpdateRequested = true; - private detailsCache: Map = new Map(); - private itemsToUpdateInfo: Map = new Map(); - - protected createResourceItem(context: IActionContext, resource: MongoVCoreResource): TreeElement | undefined { - // TODO: ClusterModel does not implement TreeElementWithExperience as other models do - // and the base class will check isTreeElementWithExperience to set this property. - // Since ClusterModel has a dbExperience property which is theoretically the same but named differently - // set the experience to the API.MongoClusters explicitly here. - context.telemetry.properties.experience = API.MongoClusters; - - if (this.detailsCacheUpdateRequested) { - void this.updateResourceCache(context, resource.subscription, 1000 * 60 * 5).then(() => { - /** - * Instances of MongoClusterItem were stored in the itemsToUpdateInfo map, - * so that when the cache is updated, the items can be refreshed. - * I had to keep all of them in the map becasuse refresh requires the actual MongoClusterItem instance. - */ - this.itemsToUpdateInfo.forEach((value: MongoVCoreResourceItem) => { - value.cluster = { - ...value.cluster, - ...this.detailsCache.get(value.cluster.id), - }; - this.refresh(value); - }); - - this.itemsToUpdateInfo.clear(); - }); - } - - // 1. extract the basic info from the element (subscription, resource group, etc., provided by Azure Resources) - let clusterInfo: ClusterModel = { - ...resource, - dbExperience: MongoClustersExperience, - } as ClusterModel; - - // 2. lookup the details in the cache, on subsequent refreshes, the details will be available in the cache - if (this.detailsCache.has(clusterInfo.id)) { - clusterInfo = { - ...clusterInfo, - ...this.detailsCache.get(clusterInfo.id), - }; - } - - const clusterItem = new MongoVCoreResourceItem(resource.subscription, clusterInfo); - - // 3. store the item in the update queue, so that when the cache is updated, the item can be refreshed - this.itemsToUpdateInfo.set(clusterItem.id, clusterItem); - - return clusterItem; - } - - async updateResourceCache( - _context: IActionContext, - subscription: AzureSubscription, - cacheDuration: number, - ): Promise { - return callWithTelemetryAndErrorHandling( - 'resolveResource.updatingResourceCache', - async (context: IActionContext) => { - try { - context.telemetry.properties.experience = API.MongoClusters; - - this.detailsCacheUpdateRequested = false; - - setTimeout(() => { - this.detailsCache.clear(); - this.detailsCacheUpdateRequested = true; - }, cacheDuration); // clear cache after 5 minutes == keep cache for 5 minutes 1000 * 60 * 5 - - const client = await createMongoClustersManagementClient(_context, subscription); - const accounts = await uiUtils.listAllIterator(client.mongoClusters.list()); - - accounts.map((mongoClusterAccount) => { - this.detailsCache.set(nonNullProp(mongoClusterAccount, 'id'), { - dbExperience: MongoClustersExperience, - id: mongoClusterAccount.id!, - name: mongoClusterAccount.name!, - resourceGroup: getResourceGroupFromId(mongoClusterAccount.id!), - - location: mongoClusterAccount.location, - serverVersion: mongoClusterAccount.properties?.serverVersion, - - systemData: { - createdAt: mongoClusterAccount.systemData?.createdAt, - }, - - sku: mongoClusterAccount.properties?.compute?.tier, - diskSize: mongoClusterAccount.properties?.storage?.sizeGb, - nodeCount: mongoClusterAccount.properties?.sharding?.shardCount, - enableHa: mongoClusterAccount.properties?.highAvailability?.targetMode !== 'Disabled', - }); - }); - } catch (e) { - console.debug({ ...context, ...subscription }); - throw e; - } - }, - ); - } -} diff --git a/src/tree/azure-resources-view/documentdb/mongo-vcore/MongoVCoreResourceItem.ts b/src/tree/azure-resources-view/documentdb/mongo-vcore/MongoVCoreResourceItem.ts deleted file mode 100644 index fece4cac5..000000000 --- a/src/tree/azure-resources-view/documentdb/mongo-vcore/MongoVCoreResourceItem.ts +++ /dev/null @@ -1,206 +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 { - AzureWizard, - callWithTelemetryAndErrorHandling, - nonNullProp, - nonNullValue, - UserCancelledError, - type IActionContext, -} from '@microsoft/vscode-azext-utils'; -import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; -import * as l10n from '@vscode/l10n'; -import * as vscode from 'vscode'; -import { ClustersClient } from '../../../../documentdb/ClustersClient'; -import { CredentialCache } from '../../../../documentdb/CredentialCache'; -import { maskSensitiveValuesInTelemetry } from '../../../../documentdb/utils/connectionStringHelpers'; -import { DocumentDBConnectionString } from '../../../../documentdb/utils/DocumentDBConnectionString'; -import { type AuthenticateWizardContext } from '../../../../documentdb/wizards/authenticate/AuthenticateWizardContext'; -import { ProvidePasswordStep } from '../../../../documentdb/wizards/authenticate/ProvidePasswordStep'; -import { ProvideUserNameStep } from '../../../../documentdb/wizards/authenticate/ProvideUsernameStep'; -import { ext } from '../../../../extensionVariables'; -import { createMongoClustersManagementClient } from '../../../../utils/azureClients'; -import { ClusterItemBase, type ClusterCredentials } from '../../../documentdb/ClusterItemBase'; -import { type ClusterModel } from '../../../documentdb/ClusterModel'; - -export class MongoVCoreResourceItem extends ClusterItemBase { - public getCredentials(): Promise { - throw new Error('Method not implemented.'); - } - constructor( - readonly subscription: AzureSubscription, - cluster: ClusterModel, - ) { - super(cluster); - } - - public async getConnectionString(): Promise { - return callWithTelemetryAndErrorHandling( - 'documentDB.mongoClusters.getConnectionString', - 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.cluster.resourceGroup!, - this.cluster.name, - ); - - if (!clusterInformation.properties?.connectionString) { - return undefined; - } - - context.valuesToMask.push(clusterInformation.properties.connectionString); - const connectionString = new DocumentDBConnectionString(clusterInformation.properties.connectionString); - maskSensitiveValuesInTelemetry(context, connectionString); - - if (clusterInformation.properties?.administrator?.userName) { - context.valuesToMask.push(clusterInformation.properties.administrator.userName); - connectionString.username = clusterInformation.properties.administrator.userName; - } - - /** - * The connection string returned from Azure does not include the actual password. - * Instead, it contains a placeholder. We explicitly set the password to an empty string here. - */ - connectionString.password = ''; - - return connectionString.toString(); - }, - ); - } - - /** - * Authenticates and connects to the MongoDB cluster. - * @param context The action context. - * @returns An instance of ClustersClient if successful; otherwise, null. - */ - protected async authenticateAndConnect(): Promise { - const result = await callWithTelemetryAndErrorHandling( - 'documentDB.mongoClusters.connect', - async (context: IActionContext) => { - ext.outputChannel.appendLine( - l10n.t('Attempting to authenticate with "{cluster}"…', { - cluster: this.cluster.name, - }), - ); - - // 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.cluster.resourceGroup!, - this.cluster.name, - ); - - if (!clusterInformation.properties?.connectionString) { - return undefined; - } - - context.valuesToMask.push(clusterInformation.properties.connectionString); - if (clusterInformation.properties?.administrator?.userName) { - context.valuesToMask.push(clusterInformation.properties.administrator.userName); - } - - const wizardContext: AuthenticateWizardContext = { - ...context, - adminUserName: clusterInformation.properties?.administrator?.userName, - resourceName: this.cluster.name, - }; - - // Prompt the user for credentials - const credentialsProvided = await this.promptForCredentials(wizardContext); - - // If the wizard was aborted or failed, return null - if (!credentialsProvided) { - return null; - } - - context.valuesToMask.push(nonNullProp(wizardContext, 'password')); - - // Cache the credentials - CredentialCache.setCredentials( - this.id, - nonNullValue(clusterInformation.properties.connectionString), - nonNullProp(wizardContext, 'selectedUserName'), - nonNullProp(wizardContext, 'password'), - // here, emulatorConfiguration is not set, as it's a resource item from Azure resources, not a workspace item, therefore, no emulator support needed - ); - - ext.outputChannel.append( - l10n.t('Connecting to the cluster as "{username}"…', { - username: wizardContext.selectedUserName ?? '', - }), - ); - - // Attempt to create the client with the provided credentials - let clustersClient: ClustersClient; - try { - clustersClient = await ClustersClient.getClient(this.id).catch((error: Error) => { - ext.outputChannel.appendLine(l10n.t('Error: {error}', { error: error.message })); - - void vscode.window.showErrorMessage( - l10n.t('Failed to connect to "{cluster}"', { cluster: this.cluster.name }), - { - modal: true, - detail: - l10n.t('Revisit connection details and try again.') + - '\n\n' + - l10n.t('Error: {error}', { error: error.message }), - }, - ); - - throw error; - }); - } catch { - // If connection fails, remove cached credentials - await ClustersClient.deleteClient(this.id); - CredentialCache.deleteCredentials(this.id); - - // Return null to indicate failure - return null; - } - - ext.outputChannel.appendLine( - l10n.t('Connected to "{cluster}" as "{username}".', { - cluster: this.cluster.name, - username: wizardContext.selectedUserName ?? '', - }), - ); - - return clustersClient; - }, - ); - - return result ?? null; - } - - /** - * Prompts the user for credentials using a wizard. - * - * @param wizardContext The wizard context. - * @returns True if the wizard completed successfully; false if the user canceled or an error occurred. - */ - private async promptForCredentials(wizardContext: AuthenticateWizardContext): Promise { - const wizard = new AzureWizard(wizardContext, { - promptSteps: [new ProvideUserNameStep(), new ProvidePasswordStep()], - title: l10n.t('Authenticate to connect with your MongoDB cluster'), - showLoadingPrompt: true, - }); - - // Prompt the user for credentials - - try { - await wizard.prompt(); // This will prompt the user; results are stored in wizardContext - } catch (error) { - if (error instanceof UserCancelledError) { - wizardContext.aborted = true; - } - } - - // Return true if the wizard completed successfully; false otherwise - return !wizardContext.aborted; - } -} diff --git a/src/tree/azure-resources-view/mongo-ru/RUBranchDataProvider.ts b/src/tree/azure-resources-view/mongo-ru/RUBranchDataProvider.ts new file mode 100644 index 000000000..f6cf3cd91 --- /dev/null +++ b/src/tree/azure-resources-view/mongo-ru/RUBranchDataProvider.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 { getResourceGroupFromId, uiUtils } from '@microsoft/vscode-azext-azureutils'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AzureResource, type BranchDataProvider } from '@microsoft/vscode-azureresources-api'; +import { CosmosDBMongoRUExperience } from '../../../DocumentDBExperiences'; +import { Views } from '../../../documentdb/Views'; +import { ext } from '../../../extensionVariables'; +import { CaseInsensitiveMap } from '../../../utils/CaseInsensitiveMap'; +import { LazyMetadataLoader } from '../../../utils/LazyMetadataLoader'; +import { createCosmosDBManagementClient } from '../../../utils/azureClients'; +import { nonNullProp } from '../../../utils/nonNull'; +import { BaseExtendedTreeDataProvider } from '../../BaseExtendedTreeDataProvider'; +import { type TreeElement } from '../../TreeElement'; +import { isTreeElementWithContextValue } from '../../TreeElementWithContextValue'; +import { type ClusterModel } from '../../documentdb/ClusterModel'; +import { RUResourceItem } from './RUCoreResourceItem'; + +// export type VCoreResource = AzureResource & +// GenericResource & { +// readonly raw: GenericResource; // Resource object from Azure SDK +// }; + +export class RUBranchDataProvider + extends BaseExtendedTreeDataProvider + implements BranchDataProvider +{ + /** + * Helper for managing lazy metadata loading with proper caching and item updates. + * This replaces the manual cache management that was previously done with + * detailsCacheUpdateRequested, detailsCache, and itemsToUpdateInfo properties. + */ + private readonly metadataLoader = new LazyMetadataLoader({ + cacheDuration: 5 * 60 * 1000, // 5 minutes + loadMetadata: async (subscription, context) => { + console.debug( + 'Loading metadata cache for %s/%s', + context.telemetry.properties.view, + context.telemetry.properties.branch, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + const managementClient = await createCosmosDBManagementClient(context, subscription as any); + const ruAccounts = (await uiUtils.listAllIterator(managementClient.databaseAccounts.list())).filter( + (account) => account.kind === 'MongoDB', + ); // ignore non-ru accounts + + console.debug( + 'Loaded metadata for %s/%s: %d entries', + context.telemetry.properties.view, + context.telemetry.properties.branch, + ruAccounts.length, + ); + + const cache = new CaseInsensitiveMap(); + ruAccounts.forEach((ruAccount) => { + cache.set(nonNullProp(ruAccount, 'id', 'ruAccount.id', 'RUBranchDataProvider.ts'), { + dbExperience: CosmosDBMongoRUExperience, + id: ruAccount.id!, + name: ruAccount.name!, + resourceGroup: getResourceGroupFromId(ruAccount.id!), + location: ruAccount.location, + serverVersion: ruAccount?.apiProperties?.serverVersion, + systemData: { + createdAt: ruAccount.systemData?.createdAt, + }, + capabilities: + ruAccount.capabilities && ruAccount.capabilities.length > 0 + ? ruAccount.capabilities + .map((cap) => cap.name) + .filter((name) => name !== undefined) + .join(', ') + : undefined, + }); + }); + return cache; + }, + updateItem: (item, metadata) => { + if (metadata) { + item.cluster = { ...item.cluster, ...metadata }; + } + }, + refreshCallback: (item) => this.refresh(item), + }); + + constructor() { + super(); + this.disposables.push(this.metadataLoader); + } + + async getChildren(element: TreeElement): Promise { + return await callWithTelemetryAndErrorHandling('getChildren', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureResourcesView; + context.telemetry.properties.branch = 'ru'; + + context.telemetry.properties.parentNodeContext = (await element.getTreeItem()).contextValue; + + // Use the enhanced method with the contextValue parameter + const children = await this.wrapGetChildrenWithErrorAndStateHandling( + element, + context, + async () => element.getChildren?.(), + { + contextValue: ['ruBranch', Views.AzureResourcesView], // This enables automatic child processing + }, + ); + + // Return the processed children directly - no additional processing needed + return children; + }); + } + + getResourceItem(resource: AzureResource): TreeElement | Thenable { + return callWithTelemetryAndErrorHandling('getResourceItem', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureResourcesView; + context.telemetry.properties.branch = 'ru'; + + // Trigger cache loading if needed + if (this.metadataLoader.needsCacheUpdate) { + void this.metadataLoader.loadCacheAndRefreshItems(resource.subscription, context); + } + + // Get metadata from cache (may be undefined if not yet loaded) + const cachedMetadata = this.metadataLoader.getCachedMetadata(resource.id); + + let clusterInfo: ClusterModel = { + ...resource, + dbExperience: CosmosDBMongoRUExperience, + } as ClusterModel; + + // Merge with cached metadata if available + if (cachedMetadata) { + clusterInfo = { ...clusterInfo, ...cachedMetadata }; + } + + const clusterItem = new RUResourceItem(resource.subscription, clusterInfo); + ext.state.wrapItemInStateHandling(clusterItem, () => this.refresh(clusterItem)); + if (isTreeElementWithContextValue(clusterItem)) { + this.appendContextValues(clusterItem, 'ruBranch', Views.AzureResourcesView); + } + + // Register item for refresh when cache loading completes + this.metadataLoader.addItemForRefresh(resource.id, clusterItem); + + return clusterItem; + }) as TreeElement | Thenable; // Cast to ensure correct type; + } +} diff --git a/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts b/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts new file mode 100644 index 000000000..6800de03a --- /dev/null +++ b/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts @@ -0,0 +1,210 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { AuthMethodId } from '../../../documentdb/auth/AuthMethod'; +import { ClustersClient } from '../../../documentdb/ClustersClient'; +import { CredentialCache } from '../../../documentdb/CredentialCache'; +import { maskSensitiveValuesInTelemetry } from '../../../documentdb/utils/connectionStringHelpers'; +import { DocumentDBConnectionString } from '../../../documentdb/utils/DocumentDBConnectionString'; +import { Views } from '../../../documentdb/Views'; +import { ext } from '../../../extensionVariables'; +import { createCosmosDBManagementClient } from '../../../utils/azureClients'; +import { nonNullValue } from '../../../utils/nonNull'; +import { ClusterItemBase, type ClusterCredentials } from '../../documentdb/ClusterItemBase'; +import { type ClusterModel } from '../../documentdb/ClusterModel'; + +export class RUResourceItem extends ClusterItemBase { + iconPath = vscode.Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'from_node_modules', + '@microsoft', + 'vscode-azext-azureutils', + 'resources', + 'azureIcons', + 'MongoClusters.svg', + ); + + constructor( + readonly subscription: AzureSubscription, + cluster: ClusterModel, + ) { + super(cluster); + } + + public async getCredentials(): Promise { + return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureResourcesView; + context.telemetry.properties.branch = 'ru'; + + const credentials = await this.getRUClusterCredentialsFromAzure( + context, + this.subscription, + this.cluster.resourceGroup!, + this.cluster.name, + ); + + return credentials; + }); + } + + /** + * Authenticates and connects to the Azure Cosmos DB for MongoDB (RU) cluster. + * No authentication prompt as we're accessing the cluster with the default credentials. + * + * @param context The action context. + * @returns An instance of ClustersClient if successful; otherwise, null. + */ + protected async authenticateAndConnect(): Promise { + const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureResourcesView; + context.telemetry.properties.branch = 'ru'; + + ext.outputChannel.appendLine( + l10n.t('Attempting to authenticate with "{cluster}"…', { + cluster: this.cluster.name, + }), + ); + + const credentials = await this.getRUClusterCredentialsFromAzure( + context, + this.subscription, + this.cluster.resourceGroup!, + this.cluster.name, + ); + + // Cache credentials and attempt connection + CredentialCache.setAuthCredentials( + this.id, + credentials.selectedAuthMethod!, + nonNullValue(credentials.connectionString, 'credentials.connectionString', 'RUCoreResourceItem.ts'), + credentials.connectionUser, + credentials.connectionPassword, + ); + + ext.outputChannel.append( + l10n.t('Connecting to the cluster as "{username}"…', { + username: credentials.connectionUser ?? '', + }), + ); + + try { + const clustersClient = await ClustersClient.getClient(this.id); + + ext.outputChannel.appendLine( + l10n.t('Connected to the cluster "{cluster}".', { + cluster: this.cluster.name, + }), + ); + + return clustersClient; + } catch (error) { + ext.outputChannel.appendLine(l10n.t('Error: {error}', { error: (error as Error).message })); + + void vscode.window.showErrorMessage( + l10n.t('Failed to connect to "{cluster}"', { cluster: this.cluster.name }), + { + modal: true, + detail: + l10n.t('Revisit connection details and try again.') + + '\n\n' + + l10n.t('Error: {error}', { error: (error as Error).message }), + }, + ); + + // Clean up failed connection + await ClustersClient.deleteClient(this.id); + CredentialCache.deleteCredentials(this.id); + + return null; + } + }); + + return result ?? null; + } + + async getRUClusterCredentialsFromAzure( + context: IActionContext, + subscription: AzureSubscription, + resourceGroup: string, + clusterName: string, + ): Promise { + // subscription comes from different azure packages in callers; cast here intentionally + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + const managementClient = await createCosmosDBManagementClient(context, subscription as any); + + // Leaving this for future maintainers in case cluster information is needed + // (becomes relevant once/when/if new authentication methods are added) + // const clusterInformation = await managementClient.databaseAccounts.get(resourceGroup, clusterName); + + const connectionStringsList = await managementClient.databaseAccounts.listConnectionStrings( + resourceGroup as string, + clusterName, + ); + + /** + * databaseAccounts.listConnectionStrings returns an array of (typically 4) connection string objects: + * + * interface DatabaseAccountConnectionString { + * readonly connectionString?: string; + * readonly description?: string; + * readonly keyKind?: Kind; + * readonly type?: Type; + * } + * + * Today we're interested in the one where "keyKind" is "Primary", but this might change in the future. + * Other known values: + * - Primary + * - Secondary + * - PrimaryReadonly + * - SecondaryReadonly + */ + + // More efficient approach + const primaryConnectionString = connectionStringsList?.connectionStrings?.find( + (cs) => cs.keyKind?.toLowerCase() === 'primary', + )?.connectionString; + + // Validate connection string's presence + if (!primaryConnectionString) { + context.telemetry.properties.error = 'missing-connection-string'; + throw new Error( + l10n.t('Authentication data (primary connection string) is missing for "{cluster}".', { + cluster: clusterName, + }), + ); + } + + context.valuesToMask.push(primaryConnectionString); + + const parsedCS = new DocumentDBConnectionString(primaryConnectionString); + maskSensitiveValuesInTelemetry(context, parsedCS); + + const username = parsedCS.username; + const password = parsedCS.password; + // do not keep secrets in the connection string + parsedCS.username = ''; + parsedCS.password = ''; + + // the connection string received sometimes contains an 'appName' entry + // with a value that's not escaped, let's just remove it as we don't use + // it here anyway. + parsedCS.searchParams.delete('appName'); + + const clusterCredentials: ClusterCredentials = { + connectionString: parsedCS.toString(), + connectionUser: username, + connectionPassword: password, + availableAuthMethods: [AuthMethodId.NativeAuth], + selectedAuthMethod: AuthMethodId.NativeAuth, + }; + + return clusterCredentials; + } +} diff --git a/src/tree/azure-workspace-view/ClustersWorkbenchBranchDataProvider.ts b/src/tree/azure-workspace-view/ClustersWorkbenchBranchDataProvider.ts new file mode 100644 index 000000000..2c8f5879e --- /dev/null +++ b/src/tree/azure-workspace-view/ClustersWorkbenchBranchDataProvider.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 { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type WorkspaceResource, type WorkspaceResourceBranchDataProvider } from '@microsoft/vscode-azureresources-api'; +import { Views } from '../../documentdb/Views'; +import { ext } from '../../extensionVariables'; +import { BaseExtendedTreeDataProvider } from '../BaseExtendedTreeDataProvider'; +import { type TreeElement } from '../TreeElement'; +import { isTreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { DocumentDbAccountsItem } from './DocumentDbAccountsItem'; + +export class ClustersWorkspaceBranchDataProvider + extends BaseExtendedTreeDataProvider + implements WorkspaceResourceBranchDataProvider +{ + getResourceItem(_element: WorkspaceResource): TreeElement | Thenable { + return callWithTelemetryAndErrorHandling('getResourceItem', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureWorkspaceView; + + return new DocumentDbAccountsItem(); + }) as unknown as TreeElement; + } + + async getChildren(element: TreeElement): Promise { + return await callWithTelemetryAndErrorHandling('getChildren', async (context: IActionContext) => { + context.telemetry.properties.view = Views.AzureWorkspaceView; + + const children = element.getChildren ? await element.getChildren() : []; + + if (!children) { + return []; + } + + // Wrap each child with state handling for refresh support + const wrappedChildren = children.map((child) => { + if (isTreeElementWithContextValue(child)) { + this.appendContextValues(child, Views.AzureWorkspaceView); + } + + const wrappedChild = ext.state.wrapItemInStateHandling(child, () => this.refresh(child)) as TreeElement; + + // Register parent-child relationship in the cache + // Note: The check for `typeof wrappedChild.id === 'string'` is necessary because `wrapItemInStateHandling` + // can process temporary nodes that don't have an `id` property, which would otherwise cause a runtime error. + if (element.id && typeof wrappedChild.id === 'string') { + this.registerRelationshipInCache(element, wrappedChild); + } + + return wrappedChild; + }) as TreeElement[]; + + return wrappedChildren; + }); + } +} diff --git a/src/tree/azure-workspace-view/DocumentDbAccountsItem.ts b/src/tree/azure-workspace-view/DocumentDbAccountsItem.ts new file mode 100644 index 000000000..28807cba6 --- /dev/null +++ b/src/tree/azure-workspace-view/DocumentDbAccountsItem.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 * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { DocumentDBExperience, type Experience } from '../../DocumentDBExperiences'; +import { type TreeElement } from '../TreeElement'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { SwitchToDocumentDbItem } from './SwitchToDocumentDbItem'; + +export class DocumentDbAccountsItem implements TreeElement, TreeElementWithExperience { + public readonly id: string; + public readonly experience: Experience; + + constructor() { + this.id = 'vscode.documentdb.workspace.accounts'; + this.experience = DocumentDBExperience; + } + + async getChildren(): Promise { + return [new SwitchToDocumentDbItem(this.id)]; + } + + getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: 'vscode.cosmosdb.workspace.mongoclusters.accounts', + label: l10n.t('DocumentDB and MongoDB Accounts'), + iconPath: new vscode.ThemeIcon('link'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } +} diff --git a/src/tree/azure-workspace-view/DocumentDbWorkspaceResourceProvider.ts b/src/tree/azure-workspace-view/DocumentDbWorkspaceResourceProvider.ts new file mode 100644 index 000000000..60942c1c3 --- /dev/null +++ b/src/tree/azure-workspace-view/DocumentDbWorkspaceResourceProvider.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 WorkspaceResource, type WorkspaceResourceProvider } from '@microsoft/vscode-azureresources-api'; +import { l10n, type ProviderResult } from 'vscode'; + +/** + * This class serves as the entry point for the workspace resources view. + * It implements the `WorkspaceResourceProvider` interface to provide resources + * that will be displayed in the workspace. + * + * In this implementation, we register the resource type we want to support, + * which in this case is `DocumentDB and MongoDB Accounts` Entry. + */ +export class DocumentDbWorkspaceResourceProvider implements WorkspaceResourceProvider { + getResources(): ProviderResult { + return [ + { + resourceType: 'vscode.documentdb.workspace.documentdb-accounts-resourceType', + id: 'vscode.documentdb.workspace.accounts', + name: l10n.t('DocumentDB and MongoDB Accounts'), // this name will be displayed in the workspace view, when no WorkspaceResourceBranchDataProvider is registered + }, + ]; + } +} diff --git a/src/tree/azure-workspace-view/SwitchToDocumentDbItem.ts b/src/tree/azure-workspace-view/SwitchToDocumentDbItem.ts new file mode 100644 index 000000000..0dcb6341c --- /dev/null +++ b/src/tree/azure-workspace-view/SwitchToDocumentDbItem.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { Views } from '../../documentdb/Views'; +import { type TreeElement } from '../TreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; + +export class SwitchToDocumentDbItem implements TreeElement, TreeElementWithContextValue { + public readonly id: string; + public readonly contextValue: string = 'treeItem_activateDocumentDbView'; + + constructor(public readonly parentId: string) { + this.id = `${parentId}/activateDocumentDbView`; + } + + public getTreeItem(): vscode.TreeItem { + const tooltip = new vscode.MarkdownString( + l10n.t( + 'The "MongoDB Connections" functionality has moved to the "DocumentDB for VS Code" extension.\n\n' + + 'If you had connections saved here in the past, they have been migrated to the new "Connections View".\n\n' + + 'Click to switch to the new view.', + ), + ); + + return { + id: this.id, + contextValue: this.contextValue, + label: l10n.t('Switch to the new "Connections View"…'), + description: l10n.t('Connections have moved'), + tooltip, + iconPath: new vscode.ThemeIcon('arrow-swap'), + command: { + command: 'vscode-documentdb.command.internal.revealView', + title: '', + arguments: [Views.ConnectionsView], + }, + }; + } +} diff --git a/src/tree/connections-view/ConnectionsBranchDataProvider.ts b/src/tree/connections-view/ConnectionsBranchDataProvider.ts index 6296a4e50..95c706540 100644 --- a/src/tree/connections-view/ConnectionsBranchDataProvider.ts +++ b/src/tree/connections-view/ConnectionsBranchDataProvider.ts @@ -3,23 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - callWithTelemetryAndErrorHandling, - createContextValue, - type IActionContext, -} from '@microsoft/vscode-azext-utils'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { Views } from '../../documentdb/Views'; -import { MongoClustersExperience } from '../../DocumentDBExperiences'; +import { DocumentDBExperience } from '../../DocumentDBExperiences'; import { ext } from '../../extensionVariables'; import { ConnectionStorageService, ConnectionType, type ConnectionItem } from '../../services/connectionStorageService'; import { createGenericElementWithContext } from '../api/createGenericElementWithContext'; +import { BaseExtendedTreeDataProvider } from '../BaseExtendedTreeDataProvider'; import { type ClusterModelWithStorage } from '../documentdb/ClusterModel'; -import { type ExtendedTreeDataProvider } from '../ExtendedTreeDataProvider'; import { type TreeElement } from '../TreeElement'; -import { isTreeElementWithContextValue, type TreeElementWithContextValue } from '../TreeElementWithContextValue'; -import { isTreeElementWithRetryChildren } from '../TreeElementWithRetryChildren'; -import { TreeParentCache } from '../TreeParentCache'; +import { isTreeElementWithContextValue } from '../TreeElementWithContextValue'; import { DocumentDBClusterItem } from './DocumentDBClusterItem'; import { LocalEmulatorsItem } from './LocalEmulators/LocalEmulatorsItem'; import { NewConnectionItemCV } from './NewConnectionItemCV'; @@ -43,61 +37,9 @@ import { NewConnectionItemCV } from './NewConnectionItemCV'; * - Child-parent relationships are registered with registerRelationship during getChildren * - The cache is selectively cleared during refresh operations */ -export class ConnectionsBranchDataProvider extends vscode.Disposable implements ExtendedTreeDataProvider { - private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter< - void | TreeElement | TreeElement[] | null | undefined - >(); - private readonly parentCache = new TreeParentCache(); - - /** - * Caches nodes whose getChildren() call has failed. - * - * This cache prevents repeated attempts to fetch children for nodes that have previously failed, - * such as when a user enters invalid credentials. By storing the failed nodes, we avoid unnecessary - * repeated calls until the error state is explicitly cleared. - * - * Key: Node ID (parent) - * Value: Array of TreeElement representing the failed children (usually an error node) - */ - private readonly errorNodeCache = new Map(); - - /** - * From vscode.TreeDataProvider: - * - * An optional event to signal that an element or root has changed. - * This will trigger the view to update the changed element/root and its children recursively (if shown). - * To signal that root has changed, do not pass any argument or pass `undefined` or `null`. - */ - get onDidChangeTreeData(): vscode.Event { - return this.onDidChangeTreeDataEmitter.event; - } - +export class ConnectionsBranchDataProvider extends BaseExtendedTreeDataProvider { constructor() { - super(() => { - this.onDidChangeTreeDataEmitter.dispose(); - }); - } - - appendContextValue(treeItem: TreeElementWithContextValue, contextValueToAppend: string): void { - // all items returned from this view need that context value assigned - const contextValues: string[] = [contextValueToAppend]; - - // keep original contextValues if any - if (treeItem.contextValue) { - contextValues.push(treeItem.contextValue); - } - - treeItem.contextValue = createContextValue(contextValues); - } - - /** - * Removes a node's error state from the failed node cache. - * This allows the node to be refreshed and its children to be re-fetched on the next refresh call. - * If not reset, the cached error children will always be returned for this node. - * @param nodeId The ID of the node to clear from the failed node cache. - */ - resetNodeErrorState(nodeId: string): void { - this.errorNodeCache.delete(nodeId); + super(); } async getChildren(element?: TreeElement): Promise { @@ -108,7 +50,7 @@ export class ConnectionsBranchDataProvider extends vscode.Disposable implements context.telemetry.properties.parentNodeContext = 'root'; // For root-level items, we should clear any existing cache first - this.parentCache.clear(); + this.clearParentCache(); const rootItems = await this.getRootItems(Views.ConnectionsView); if (!rootItems) { @@ -120,67 +62,40 @@ export class ConnectionsBranchDataProvider extends vscode.Disposable implements // Now process and add each root item to the cache for (const item of rootItems) { if (isTreeElementWithContextValue(item)) { - this.appendContextValue(item, Views.ConnectionsView); + this.appendContextValues(item, Views.ConnectionsView); } // Add root items to the cache - if (item.id) { - this.parentCache.registerNode(item); - } + this.registerNodeInCache(item); } return rootItems; } - // 1. Check if we have a cached error for this element - // - // This prevents repeated attempts to fetch children for nodes that have previously failed - // (e.g., due to invalid credentials or connection issues). - if (element.id && this.errorNodeCache.has(element.id)) { - context.telemetry.properties.usedCachedErrorNode = 'true'; - return this.errorNodeCache.get(element.id); - } - context.telemetry.properties.parentNodeContext = (await element.getTreeItem()).contextValue; - // 2. Fetch the children of the current element - const children = await element.getChildren?.(); - context.telemetry.measurements.childrenCount = children?.length ?? 0; - - // 3. Check if the returned children contain an error node - // This means the operation failed (eg. authentication) - if (isTreeElementWithRetryChildren(element) && element.hasRetryNode(children)) { - // append helpful nodes to the error node - children?.push( - createGenericElementWithContext({ - contextValue: 'error', - id: `${element.id}/updateCredentials`, - label: vscode.l10n.t('Click here to update credentials'), - iconPath: new vscode.ThemeIcon('key'), - commandId: 'vscode-documentdb.command.connectionsView.updateCredentials', - commandArgs: [element], - }), - ); - // Store the error node(s) in our cache for future refreshes - this.errorNodeCache.set(element.id, children ?? []); - context.telemetry.properties.cachedErrorNode = 'true'; - } - - return children?.map((child) => { - if (child.id) { - if (isTreeElementWithContextValue(child)) { - this.appendContextValue(child, Views.ConnectionsView); - } - - // Register parent-child relationship in the cache - if (element.id && child.id) { - this.parentCache.registerRelationship(element, child); - } + // Use the enhanced method with the contextValue parameter + const children = await this.wrapGetChildrenWithErrorAndStateHandling( + element, + context, + async () => element.getChildren?.(), + { + contextValue: Views.ConnectionsView, // This enables automatic child processing + createHelperNodes: (el) => [ + createGenericElementWithContext({ + contextValue: 'error', + id: `${el.id}/updateCredentials`, + label: vscode.l10n.t('Click here to update credentials'), + iconPath: new vscode.ThemeIcon('key'), + commandId: 'vscode-documentdb.command.connectionsView.updateCredentials', + commandArgs: [el], + }) as TreeElement, + ], + }, + ); - return ext.state.wrapItemInStateHandling(child, () => this.refresh(child)) as TreeElement; - } - return child; - }); + // Return the processed children directly - no additional processing needed + return children; }); } @@ -208,7 +123,7 @@ export class ConnectionsBranchDataProvider extends vscode.Disposable implements id: `${parentId}/${connection.id}`, storageId: connection.id, name: connection.name, - dbExperience: MongoClustersExperience, + dbExperience: DocumentDBExperience, connectionString: connection?.secrets?.connectionString ?? undefined, }; @@ -221,88 +136,4 @@ export class ConnectionsBranchDataProvider extends vscode.Disposable implements (item) => ext.state.wrapItemInStateHandling(item, () => this.refresh(item)) as TreeElement, ); } - - async getTreeItem(element: TreeElement): Promise { - return element.getTreeItem(); - } - - /** - * Refreshes the tree data. - * This will trigger the view to update the changed element/root and its children recursively (if shown). - * - * @param element The element to refresh. If not provided, the entire tree will be refreshed. - * - * Note: This implementation handles both current and stale element references. - * If a stale reference is provided but has an ID, it will attempt to find the current - * reference in the tree before refreshing. - */ - refresh(element?: TreeElement): void { - if (element?.id) { - // We have an element with an ID - - // Handle potential stale reference issue: - // VS Code's TreeView API relies on object identity (reference equality), - // not just ID equality. Find the current reference before clearing the cache. - void this.findAndRefreshCurrentElement(element); - } else { - // No element or no ID, refresh the entire tree - this.parentCache.clear(); - this.onDidChangeTreeDataEmitter.fire(element); - } - } - - /** - * Helper method to find the current instance of an element by ID and refresh it. - * This addresses the issue where stale references won't properly refresh the tree. - * - * @param element Potentially stale element reference - */ - private async findAndRefreshCurrentElement(element: TreeElement): Promise { - try { - // First try to find the current instance with this ID - const currentElement = await this.findNodeById(element.id!); - - // AFTER finding the element, update the cache: - // 1. Clear the cache for this ID to remove any stale references - // (drops the element and its children) - this.parentCache.clear(element.id!); - // 2. Re-register the node (but not its children) - if (currentElement?.id) { - this.parentCache.registerNode(currentElement); - } - - if (currentElement) { - // We found the current instance, use it for refresh - this.onDidChangeTreeDataEmitter.fire(currentElement); - } else { - // Current instance not found, fallback to using the provided element - // This may not work if it's truly a stale reference, but we've tried our best - this.onDidChangeTreeDataEmitter.fire(element); - } - } catch (error) { - // If anything goes wrong during the lookup, still attempt the refresh with the original element - // and clear the cache for this ID - console.log(`Error finding current element for refresh: ${error}`); - this.parentCache.clear(element.id!); - this.onDidChangeTreeDataEmitter.fire(element); - } - } - - // Implement getParent using the cache - getParent(element: TreeElement): TreeElement | null | undefined { - return this.parentCache.getParent(element); - } - - async findNodeById(id: string, enableRecursiveSearch?: boolean): Promise { - if (enableRecursiveSearch) { - // Pass this.getChildren as the second parameter to enable recursive search - return this.parentCache.findNodeById( - id, - this.getChildren.bind(this) as (element: TreeElement) => Promise, - ); - } else { - // If recursive search is not enabled, we only search in the known nodes - return this.parentCache.findNodeById(id); - } - } } diff --git a/src/tree/connections-view/DocumentDBClusterItem.ts b/src/tree/connections-view/DocumentDBClusterItem.ts index 4365c3aaf..cda50be24 100644 --- a/src/tree/connections-view/DocumentDBClusterItem.ts +++ b/src/tree/connections-view/DocumentDBClusterItem.ts @@ -6,12 +6,12 @@ import { AzureWizard, callWithTelemetryAndErrorHandling, - nonNullProp, UserCancelledError, type IActionContext, } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; +import { nonNullProp } from '../../utils/nonNull'; import { authMethodFromString, AuthMethodId, authMethodsFromString } from '../../documentdb/auth/AuthMethod'; import { ClustersClient } from '../../documentdb/ClustersClient'; @@ -130,7 +130,12 @@ export class DocumentDBClusterItem extends ClusterItemBase implements TreeElemen username = wizardContext.selectedUserName; password = wizardContext.password; - authMethod = nonNullProp(wizardContext, 'selectedAuthMethod'); + authMethod = nonNullProp( + wizardContext, + 'selectedAuthMethod', + 'wizardContext.selectedAuthMethod', + 'DocumentDBClusterItem.ts', + ); if (wizardContext.saveCredentials) { ext.outputChannel.append( diff --git a/src/tree/connections-view/LocalEmulators/LocalEmulatorsItem.ts b/src/tree/connections-view/LocalEmulators/LocalEmulatorsItem.ts index 86c150ad0..5b7778ece 100644 --- a/src/tree/connections-view/LocalEmulators/LocalEmulatorsItem.ts +++ b/src/tree/connections-view/LocalEmulators/LocalEmulatorsItem.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; import path from 'path'; import { getResourcesPath, type IThemedIconPath } from '../../../constants'; -import { MongoClustersExperience } from '../../../DocumentDBExperiences'; +import { DocumentDBExperience } from '../../../DocumentDBExperiences'; import { ConnectionStorageService, ConnectionType, @@ -23,7 +23,7 @@ import { NewEmulatorConnectionItemCV } from './NewEmulatorConnectionItemCV'; export class LocalEmulatorsItem implements TreeElement, TreeElementWithContextValue { public readonly id: string; - public contextValue: string = 'treeItem.LocalEmulators'; + public contextValue: string = 'treeItem_LocalEmulators'; constructor(public readonly parentId: string) { this.id = `${parentId}/localEmulators`; @@ -43,7 +43,7 @@ export class LocalEmulatorsItem implements TreeElement, TreeElementWithContextVa id: `${this.id}/${connection.id}`, storageId: connection.id, name: connection.name, - dbExperience: MongoClustersExperience, + dbExperience: DocumentDBExperience, connectionString: connection?.secrets?.connectionString, emulatorConfiguration: emulatorConfiguration, }; @@ -55,8 +55,8 @@ export class LocalEmulatorsItem implements TreeElement, TreeElementWithContextVa } private iconPath: IThemedIconPath = { - light: path.join(getResourcesPath(), 'icons', 'vscode-documentdb-icon-blue.svg'), - dark: path.join(getResourcesPath(), 'icons', 'vscode-documentdb-icon.svg'), + light: path.join(getResourcesPath(), 'icons', 'vscode-documentdb-icon-light-themes.svg'), + dark: path.join(getResourcesPath(), 'icons', 'vscode-documentdb-icon-dark-themes.svg'), }; public getTreeItem(): vscode.TreeItem { diff --git a/src/tree/connections-view/LocalEmulators/NewEmulatorConnectionItemCV.ts b/src/tree/connections-view/LocalEmulators/NewEmulatorConnectionItemCV.ts index d7818df5a..9bdbe9676 100644 --- a/src/tree/connections-view/LocalEmulators/NewEmulatorConnectionItemCV.ts +++ b/src/tree/connections-view/LocalEmulators/NewEmulatorConnectionItemCV.ts @@ -10,7 +10,7 @@ import { type TreeElementWithContextValue } from '../../TreeElementWithContextVa export class NewEmulatorConnectionItemCV implements TreeElement, TreeElementWithContextValue { public readonly id: string; - public contextValue: string = 'treeItem.newEmulatorConnection'; + public contextValue: string = 'treeItem_newEmulatorConnection'; constructor(public readonly parentId: string) { this.id = `${parentId}/newEmulatorConnection`; diff --git a/src/tree/connections-view/NewConnectionItemCV.ts b/src/tree/connections-view/NewConnectionItemCV.ts index b65f47305..ee0df0722 100644 --- a/src/tree/connections-view/NewConnectionItemCV.ts +++ b/src/tree/connections-view/NewConnectionItemCV.ts @@ -13,7 +13,7 @@ import { type TreeElementWithContextValue } from '../TreeElementWithContextValue */ export class NewConnectionItemCV implements TreeElement, TreeElementWithContextValue { public readonly id: string; - public contextValue: string = 'treeItem.newConnection'; + public contextValue: string = 'treeItem_newConnection'; constructor(public readonly parentId: string) { this.id = `${parentId}/newConnection`; diff --git a/src/tree/discovery-view/DiscoveryBranchDataProvider.ts b/src/tree/discovery-view/DiscoveryBranchDataProvider.ts index 3128dd6ce..f4f1bc9cf 100644 --- a/src/tree/discovery-view/DiscoveryBranchDataProvider.ts +++ b/src/tree/discovery-view/DiscoveryBranchDataProvider.ts @@ -3,20 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - callWithTelemetryAndErrorHandling, - createContextValue, - type IActionContext, -} from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import { Views } from '../../documentdb/Views'; import { ext } from '../../extensionVariables'; import { DiscoveryService } from '../../services/discoveryServices'; -import { type ExtendedTreeDataProvider } from '../ExtendedTreeDataProvider'; +import { BaseExtendedTreeDataProvider } from '../BaseExtendedTreeDataProvider'; import { type TreeElement } from '../TreeElement'; -import { isTreeElementWithContextValue, type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { isTreeElementWithContextValue } from '../TreeElementWithContextValue'; import { isTreeElementWithRetryChildren } from '../TreeElementWithRetryChildren'; -import { TreeParentCache } from '../TreeParentCache'; /** * Tree data provider for the Discovery view. @@ -44,7 +38,7 @@ import { TreeParentCache } from '../TreeParentCache'; * (getChildrenPromises) provides efficient tree operations even for slow-loading discovery * sources that may involve network requests. */ -export class DiscoveryBranchDataProvider extends vscode.Disposable implements ExtendedTreeDataProvider { +export class DiscoveryBranchDataProvider extends BaseExtendedTreeDataProvider { /** * Tracks the current root items in the tree. * @@ -66,52 +60,8 @@ export class DiscoveryBranchDataProvider extends vscode.Disposable implements Ex */ private getChildrenPromises = new Map>(); - /** - * Caches nodes whose getChildren() call has failed. - * - * This cache prevents repeated attempts to fetch children for nodes that have previously failed, - * such as when a user enters invalid credentials. By storing the failed nodes, we avoid unnecessary - * repeated calls until the error state is explicitly cleared. - * - * Key: Node ID (parent) - * Value: Array of TreeElement representing the failed children (usually an error node) - */ - private readonly errorNodeCache = new Map(); - - /** - * Cache for tracking parent-child relationships to support the getParent method. - */ - private readonly parentCache = new TreeParentCache(); - - private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter< - void | TreeElement | TreeElement[] | null | undefined - >(); - - /** - * From vscode.TreeDataProvider: - * - * An optional event to signal that an element or root has changed. - * This will trigger the view to update the changed element/root and its children recursively (if shown). - * To signal that root has changed, do not pass any argument or pass `undefined` or `null`. - */ - get onDidChangeTreeData(): vscode.Event { - return this.onDidChangeTreeDataEmitter.event; - } - - /** - * Removes a node's error state from the failed node cache. - * This allows the node to be refreshed and its children to be re-fetched on the next refresh call. - * If not reset, the cached error children will always be returned for this node. - * @param nodeId The ID of the node to clear from the failed node cache. - */ - resetNodeErrorState(nodeId: string): void { - this.errorNodeCache.delete(nodeId); - } - constructor() { - super(() => { - this.onDidChangeTreeDataEmitter.dispose(); - }); + super(); } async getChildren(element: TreeElement): Promise { @@ -132,29 +82,20 @@ export class DiscoveryBranchDataProvider extends vscode.Disposable implements Ex }); } - appendContextValue(treeItem: TreeElementWithContextValue, contextValueToAppend: string): void { - // all items returned from this view need that context value assigned - const contextValues: string[] = [contextValueToAppend]; - - // keep original contextValues if any - if (treeItem.contextValue) { - contextValues.push(treeItem.contextValue); - } - - treeItem.contextValue = createContextValue(contextValues); - } - /** * Helper to get root items for the tree. * Root items here are all the regiestered and enabled discovery providers. */ - // eslint-disable-next-line @typescript-eslint/require-await + private async getRootItems(): Promise { // Reset the set of root items this.currentRootItems = new WeakSet(); // Clear the parent cache when retrieving root items - this.parentCache.clear(); + this.clearParentCache(); + + await this.renameLegacyProviders(); + await this.addDiscoveryProviderPromotionIfNeeded('azure-mongo-ru-discovery'); // Get the list of active discovery provider IDs from global state const activeDiscoveryProviderIds = ext.context.globalState.get('activeDiscoveryProviderIds', []); @@ -179,8 +120,7 @@ export class DiscoveryBranchDataProvider extends vscode.Disposable implements Ex const rootItem = discoveryProvider.getDiscoveryTreeRootItem(Views.DiscoveryView); if (isTreeElementWithContextValue(rootItem)) { - this.appendContextValue(rootItem, Views.DiscoveryView); - this.appendContextValue(rootItem, 'rootItem'); + this.appendContextValues(rootItem, Views.DiscoveryView, 'rootItem'); } // Wrap the root item with state handling for refresh support @@ -193,7 +133,7 @@ export class DiscoveryBranchDataProvider extends vscode.Disposable implements Ex // Register root item in the parent cache if (wrappedInStateHandling.id) { - this.parentCache.registerNode(wrappedInStateHandling); + this.registerNodeInCache(wrappedInStateHandling); } rootItems.push(wrappedInStateHandling); @@ -226,9 +166,9 @@ export class DiscoveryBranchDataProvider extends vscode.Disposable implements Ex // // This prevents repeated attempts to fetch children for nodes that have previously failed // (e.g., due to invalid credentials or connection issues). - if (element.id && this.errorNodeCache.has(element.id)) { + if (element.id && this.failedChildrenCache.has(element.id)) { context.telemetry.properties.usedCachedErrorNode = 'true'; - return this.errorNodeCache.get(element.id); + return this.failedChildrenCache.get(element.id); } // Start fetching children @@ -245,14 +185,14 @@ export class DiscoveryBranchDataProvider extends vscode.Disposable implements Ex // This means the operation failed (eg. authentication) if (isTreeElementWithRetryChildren(element) && element.hasRetryNode(children)) { // Store the error node(s) in our cache for future refreshes - this.errorNodeCache.set(element.id, children ?? []); + this.failedChildrenCache.set(element.id, children ?? []); context.telemetry.properties.cachedErrorNode = 'true'; } // Wrap each child with state handling for refresh support return children.map((child) => { if (isTreeElementWithContextValue(child)) { - this.appendContextValue(child, Views.DiscoveryView); + this.appendContextValues(child, Views.DiscoveryView); } const wrappedChild = ext.state.wrapItemInStateHandling(child, () => @@ -263,7 +203,7 @@ export class DiscoveryBranchDataProvider extends vscode.Disposable implements Ex // Note: The check for `typeof wrappedChild.id === 'string'` is necessary because `wrapItemInStateHandling` // can process temporary nodes that don't have an `id` property, which would otherwise cause a runtime error. if (element.id && typeof wrappedChild.id === 'string') { - this.parentCache.registerRelationship(element, wrappedChild); + this.registerRelationshipInCache(element, wrappedChild); } return wrappedChild; @@ -286,96 +226,70 @@ export class DiscoveryBranchDataProvider extends vscode.Disposable implements Ex return null; } - async getTreeItem(element: TreeElement): Promise { - /** note that due to caching done by the TreeElementStateManager, - * changes to the TreeItem added here might get lost */ - return element.getTreeItem(); - } + private async addDiscoveryProviderPromotionIfNeeded(providerId: string): Promise { + const promotionFlagKey = `discoveryProviderPromotionProcessed:${providerId}`; + const promotionAlreadyShown = ext.context.globalState.get(promotionFlagKey, false); - /** - * Refreshes the tree data. - * This will trigger the view to update the changed element/root and its children recursively (if shown). - * - * @param element The element to refresh. If not provided, the entire tree will be refreshed. - * - * Note: This implementation handles both current and stale element references. - * If a stale reference is provided but has an ID, it will attempt to find the current - * reference in the tree before refreshing. - */ - refresh(element?: TreeElement): void { - if (element?.id) { - // We have an element with an ID - - // Handle potential stale reference issue: - // VS Code's TreeView API relies on object identity (reference equality), - // not just ID equality. Find the current reference before clearing the cache. - void this.findAndRefreshCurrentElement(element); - } else { - // No element or no ID, refresh the entire tree - this.parentCache.clear(); - this.onDidChangeTreeDataEmitter.fire(element); + if (promotionAlreadyShown) { + // Already shown/processed previously — do nothing. + return; } - } - /** - * Helper method to find the current instance of an element by ID and refresh it. - * This addresses the issue where stale references won't properly refresh the tree. - * - * @param element Potentially stale element reference - */ - private async findAndRefreshCurrentElement(element: TreeElement): Promise { - try { - // First try to find the current instance with this ID - const currentElement = await this.findNodeById(element.id!); - - // AFTER finding the element, update the cache: - // 1. Clear the cache for this ID to remove any stale references - // (drops the element and its children) - this.parentCache.clear(element.id!); - // 2. Re-register the node (but not its children) - if (currentElement?.id) { - this.parentCache.registerNode(currentElement); + // If there are no registered discovery providers at all, mark the promotion as shown + // and return early. The goal is to only show the promotion to users who have some + // discovery providers active/installed. + const registeredProviders = DiscoveryService.listProviders(); + if (!registeredProviders || registeredProviders.length === 0) { + try { + await ext.context.globalState.update(promotionFlagKey, true); + } catch { + // ignore storage errors for this best-effort write } + return; + } - if (currentElement) { - // We found the current instance, use it for refresh - this.onDidChangeTreeDataEmitter.fire(currentElement); - } else { - // Current instance not found, fallback to using the provided element - // This may not work if it's truly a stale reference, but we've tried our best - this.onDidChangeTreeDataEmitter.fire(element); + // Only proceed if the provider is actually available + const provider = DiscoveryService.getProvider(providerId); + if (!provider) { + // Provider not registered with DiscoveryService; skip for now. + return; + } + + // Read current active provider IDs + const activeProviderIds = ext.context.globalState.get('activeDiscoveryProviderIds', []); + + // If not present, register it + if (!activeProviderIds.includes(providerId)) { + const updated = [...activeProviderIds, providerId]; + try { + await ext.context.globalState.update('activeDiscoveryProviderIds', updated); + } catch (error) { + console.error(`Failed to update activeDiscoveryProviderIds: ${(error as Error).message}`); } - } catch (error) { - // If anything goes wrong during the lookup, still attempt the refresh with the original element - // and clear the cache for this ID - console.log(`Error finding current element for refresh: ${error}`); - this.parentCache.clear(element.id!); - this.onDidChangeTreeDataEmitter.fire(element); } - } - /** - * Gets the parent of a tree element. Required for TreeView.reveal functionality. - * - * @param element The element for which to find the parent - * @returns The parent element, or undefined if the element is a root item - */ - getParent(element: TreeElement): TreeElement | null | undefined { - return this.parentCache.getParent(element); + // Mark that we've added/shown the promotion for this provider so we don't repeat it + try { + await ext.context.globalState.update(promotionFlagKey, true); + } catch { + // ignore + } } - /** - * Finds a node in the tree by its ID. - * - * @param id The ID of the node to find - * @returns A Promise that resolves to the found node or undefined if not found - */ - async findNodeById(id: string): Promise { - return this.parentCache.findNodeById(id, async (element) => { - if (!element.getChildren) { - return undefined; + private async renameLegacyProviders(): Promise { + try { + const activeProviderIds = ext.context.globalState.get('activeDiscoveryProviderIds', []); + if (activeProviderIds.includes('azure-discovery')) { + { + const updated = ext.context.globalState + .get('activeDiscoveryProviderIds', []) + .filter((id) => id !== 'azure-discovery'); + updated.push('azure-mongo-vcore-discovery'); + await ext.context.globalState.update('activeDiscoveryProviderIds', updated); + } } - return element.getChildren(); - }); + } catch { + // ignore storage errors for this best-effort write + } } } diff --git a/src/tree/documentdb/ClusterItemBase.ts b/src/tree/documentdb/ClusterItemBase.ts index b26959b62..d92baa62d 100644 --- a/src/tree/documentdb/ClusterItemBase.ts +++ b/src/tree/documentdb/ClusterItemBase.ts @@ -46,7 +46,7 @@ export abstract class ClusterItemBase { public readonly id: string; public readonly experience: Experience; - public contextValue: string = 'treeItem.mongoCluster'; + public contextValue: string = 'treeItem_documentdbcluster'; protected descriptionOverride?: string; protected tooltipOverride?: string | vscode.MarkdownString; @@ -71,7 +71,7 @@ export abstract class ClusterItemBase protected constructor(public cluster: ClusterModel) { this.id = cluster.id ?? ''; this.experience = cluster.dbExperience; - this.experienceContextValue = `experience.${this.experience.api}`; + this.experienceContextValue = `experience_${this.experience.api}`; this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } @@ -144,7 +144,7 @@ export abstract class ClusterItemBase if (databases.length === 0) { return [ createGenericElement({ - contextValue: createContextValue(['treeItem.no-databases', this.experienceContextValue]), + contextValue: createContextValue(['treeItem_no-databases', this.experienceContextValue]), id: `${this.id}/no-databases`, label: l10n.t('Create Database…'), iconPath: new vscode.ThemeIcon('plus'), @@ -187,7 +187,9 @@ export abstract class ClusterItemBase ? this.descriptionOverride : this.cluster.sku !== undefined ? `(${this.cluster.sku})` - : false, + : this.cluster.serverVersion !== undefined + ? `v${this.cluster.serverVersion}` + : false, iconPath: this.iconPath ?? undefined, tooltip: this.tooltipOverride ? this.tooltipOverride @@ -204,6 +206,7 @@ export abstract class ClusterItemBase : '') + (this.cluster.nodeCount ? `- Node Count: **${this.cluster.nodeCount}**\n\n` : '') + (this.cluster.serverVersion ? `- Server Version: **${this.cluster.serverVersion}**\n` : '') + + (this.cluster.capabilities ? `- Capabilities: **${this.cluster.capabilities}**\n` : '') + (this.cluster.systemData?.createdAt ? `---\n- Created Date: **${this.cluster.systemData.createdAt.toLocaleString()}**\n` : ''), diff --git a/src/tree/documentdb/ClusterModel.ts b/src/tree/documentdb/ClusterModel.ts index a1b517fa3..3c6859941 100644 --- a/src/tree/documentdb/ClusterModel.ts +++ b/src/tree/documentdb/ClusterModel.ts @@ -46,6 +46,7 @@ interface ResourceModelInUse extends Resource { connectionString?: string; location?: string; + capabilities?: string; serverVersion?: string; systemData?: { createdAt?: Date; diff --git a/src/tree/documentdb/CollectionItem.ts b/src/tree/documentdb/CollectionItem.ts index c277440c9..fcec32a84 100644 --- a/src/tree/documentdb/CollectionItem.ts +++ b/src/tree/documentdb/CollectionItem.ts @@ -17,7 +17,7 @@ import { IndexesItem } from './IndexesItem'; export class CollectionItem implements TreeElement, TreeElementWithExperience, TreeElementWithContextValue { public readonly id: string; public readonly experience: Experience; - public contextValue: string = 'treeItem.collection'; + public contextValue: string = 'treeItem_collection'; private readonly experienceContextValue: string = ''; @@ -28,7 +28,7 @@ export class CollectionItem implements TreeElement, TreeElementWithExperience, T ) { this.id = `${cluster.id}/${databaseInfo.name}/${collectionInfo.name}`; this.experience = cluster.dbExperience; - this.experienceContextValue = `experience.${this.experience.api}`; + this.experienceContextValue = `experience_${this.experience.api}`; this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } diff --git a/src/tree/documentdb/DatabaseItem.ts b/src/tree/documentdb/DatabaseItem.ts index 6e933e2ae..5bdf437d8 100644 --- a/src/tree/documentdb/DatabaseItem.ts +++ b/src/tree/documentdb/DatabaseItem.ts @@ -17,7 +17,7 @@ import { CollectionItem } from './CollectionItem'; export class DatabaseItem implements TreeElement, TreeElementWithExperience, TreeElementWithContextValue { public readonly id: string; public readonly experience: Experience; - public contextValue: string = 'treeItem.database'; + public contextValue: string = 'treeItem_database'; private readonly experienceContextValue: string = ''; @@ -27,7 +27,7 @@ export class DatabaseItem implements TreeElement, TreeElementWithExperience, Tre ) { this.id = `${cluster.id}/${databaseInfo.name}`; this.experience = cluster.dbExperience; - this.experienceContextValue = `experience.${this.experience?.api}`; + this.experienceContextValue = `experience_${this.experience?.api}`; this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } @@ -39,7 +39,7 @@ export class DatabaseItem implements TreeElement, TreeElementWithExperience, Tre // no databases in there: return [ createGenericElement({ - contextValue: createContextValue(['treeItem.no-collections', this.experienceContextValue]), + contextValue: createContextValue(['treeItem_no-collections', this.experienceContextValue]), id: `${this.id}/no-collections`, label: l10n.t('Create Collection…'), iconPath: new vscode.ThemeIcon('plus'), diff --git a/src/tree/documentdb/DocumentsItem.ts b/src/tree/documentdb/DocumentsItem.ts index bada889a5..43f04b23e 100644 --- a/src/tree/documentdb/DocumentsItem.ts +++ b/src/tree/documentdb/DocumentsItem.ts @@ -17,7 +17,7 @@ import { type CollectionItem } from './CollectionItem'; export class DocumentsItem implements TreeElement, TreeElementWithExperience, TreeElementWithContextValue { public readonly id: string; public readonly experience: Experience; - public contextValue: string = 'treeItem.documents'; + public contextValue: string = 'treeItem_documents'; private readonly experienceContextValue: string = ''; @@ -38,7 +38,7 @@ export class DocumentsItem implements TreeElement, TreeElementWithExperience, Tr ) { this.id = `${cluster.id}/${databaseInfo.name}/${collectionInfo.name}/documents`; this.experience = cluster.dbExperience; - this.experienceContextValue = `experience.${this.experience.api}`; + this.experienceContextValue = `experience_${this.experience.api}`; this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } diff --git a/src/tree/documentdb/IndexItem.ts b/src/tree/documentdb/IndexItem.ts index 32b986687..ffc389594 100644 --- a/src/tree/documentdb/IndexItem.ts +++ b/src/tree/documentdb/IndexItem.ts @@ -15,7 +15,7 @@ import { type ClusterModel } from './ClusterModel'; export class IndexItem implements TreeElement, TreeElementWithExperience, TreeElementWithContextValue { public readonly id: string; public readonly experience: Experience; - public contextValue: string = 'treeItem.index'; + public contextValue: string = 'treeItem_index'; private readonly experienceContextValue: string = ''; @@ -27,7 +27,7 @@ export class IndexItem implements TreeElement, TreeElementWithExperience, TreeEl ) { this.id = `${cluster.id}/${databaseInfo.name}/${collectionInfo.name}/indexes/${indexInfo.name}`; this.experience = cluster.dbExperience; - this.experienceContextValue = `experience.${this.experience.api}`; + this.experienceContextValue = `experience_${this.experience.api}`; this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } diff --git a/src/tree/documentdb/IndexesItem.ts b/src/tree/documentdb/IndexesItem.ts index 19112b8a6..2308ea2d1 100644 --- a/src/tree/documentdb/IndexesItem.ts +++ b/src/tree/documentdb/IndexesItem.ts @@ -17,7 +17,7 @@ import { IndexItem } from './IndexItem'; export class IndexesItem implements TreeElement, TreeElementWithExperience, TreeElementWithContextValue { public readonly id: string; public readonly experience: Experience; - public contextValue: string = 'treeItem.indexes'; + public contextValue: string = 'treeItem_indexes'; private readonly experienceContextValue: string = ''; @@ -28,7 +28,7 @@ export class IndexesItem implements TreeElement, TreeElementWithExperience, Tree ) { this.id = `${cluster.id}/${databaseInfo.name}/${collectionInfo.name}/indexes`; this.experience = cluster.dbExperience; - this.experienceContextValue = `experience.${this.experience.api}`; + this.experienceContextValue = `experience_${this.experience.api}`; this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } diff --git a/src/tree/workspace-api/SharedWorkspaceResourceProvider.ts b/src/tree/workspace-api/SharedWorkspaceResourceProvider.ts deleted file mode 100644 index 39cc3fc2f..000000000 --- a/src/tree/workspace-api/SharedWorkspaceResourceProvider.ts +++ /dev/null @@ -1,135 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* eslint-disable */ -import { type TreeElementWithId } from '@microsoft/vscode-azext-utils'; -import { type WorkspaceResource, type WorkspaceResourceProvider } from '@microsoft/vscode-azureresources-api'; -import * as l10n from '@vscode/l10n'; -import type * as vscode from 'vscode'; - -/** - * Enum representing the types of resources that can be registered in the workspace. - * - * This enum is used to define the types of resources that can be registered within the workspace. - * By defining a type here, you can then implement and register a `WorkspaceResourceBranchDataProvider` - * and use the type defined here during the registration process. - * - * Example usage: - * - * ```typescript - * // Implement your WorkspaceResourceBranchDataProvider - * class ClustersWorkspaceBranchDataProvider implements WorkspaceResourceBranchDataProvider { - * // Implementation details... - * } - * - * // Register the provider with the type defined in the enum - * ext.rgApiV2.resources.registerWorkspaceResourceBranchDataProvider( - * WorkspaceResourceType.MongoClusters, - * new ClustersWorkspaceBranchDataProvider(), - * ); - * ``` - */ -export enum WorkspaceResourceType { - MongoClusters = 'vscode.cosmosdb.workspace.mongoclusters-resourceType', - AttachedAccounts = 'vscode.cosmosdb.workspace.attachedaccounts-resourceType', -} - -/** - * This class serves as the entry point for the workspace resources view. - * It implements the `WorkspaceResourceProvider` interface to provide resources - * that will be displayed in the workspace. - * - * In this implementation, we register the resource type we want to support, - * which in this case is `MongoClusters`. The `getResources` method returns - * an array of `WorkspaceResource` objects, each representing a resource type - * that will be available in the workspace. - * - * By implementing and registering `WorkspaceResourceBranchDataProvider`, - * we can create dedicated providers for each resource type, allowing for - * more specialized handling and display of different types of resources - * within the workspace. - */ -export class SharedWorkspaceResourceProvider implements WorkspaceResourceProvider { - getResources(): vscode.ProviderResult { - return [ - { - resourceType: WorkspaceResourceType.MongoClusters, - id: 'vscode.cosmosdb.workspace.mongoclusters', - name: l10n.t('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: l10n.t('CosmosDB Accounts'), - }, - ]; - } -} - -/** - * Extracts the workspace resource ID from a tree item's full ID. - * - * @param node - The tree item node containing an ID property - * @returns The extracted resource ID (the last segment of the full ID path) - * @throws Error if the ID is not a valid workspace resource ID or doesn't contain a path separator - * - * @remarks We store the workspace resources by their initial Id based on their endpoint, - * however when building the Tree branch we nest the Ids with their parents resulting - * in node.id being like `${WorkspaceResourceType.AttachedAccounts}/${resourceId}` - * - * When mapping back to Ids being used in the storage, always use this function to validate the node - * and get the right storage Id for a node. - */ -export function getWorkspaceResourceIdFromTreeItem(node: TreeElementWithId): string { - if (getWorkspaceResourceTypeFromFullId(node.id) === undefined) { - throw new Error(l10n.t('Invalid workspace resource ID: {0}', node.id)); - } - - const trimmedId = node.id.endsWith('/') ? node.id.slice(0, -1) : node.id; - const lastIndex = trimmedId.lastIndexOf('/'); - if (lastIndex === -1) { - throw new Error(l10n.t('Invalid workspace resource ID: {0}', node.id)); - } - // Extract the last segment of the ID - return trimmedId.substring(lastIndex + 1); -} - -/** - * Extracts the workspace resource ID from a MongoDB tree item. - * - * Unlike standard tree items, MongoDB items contain '/' characters in their IDs, - * requiring special handling to correctly extract the resource identifier. - * - * This function removes the expected prefix structure to isolate the actual resource ID. - * - * @param node - The MongoDB tree item node containing an ID property - * @returns The extracted MongoDB resource ID - * @throws Error if the ID is not a valid workspace resource ID - * - * @remarks Long-term solution would be to avoid '/' characters within individual ID segments. - */ -export function getWorkspaceResourceIdFromMongoTreeItem(node: TreeElementWithId): string { - if (getWorkspaceResourceTypeFromFullId(node.id) === undefined) { - throw new Error(l10n.t('Invalid workspace resource ID: {0}', node.id)); - } - - const trimmedId = node.id.endsWith('/') ? node.id.slice(0, -1) : node.id; - - const prefix = `${WorkspaceResourceType.MongoClusters}/`; - const cleanId = trimmedId.startsWith(prefix + 'localEmulators/') - ? trimmedId.substring(prefix.length + 'localEmulators/'.length) - : trimmedId.substring(prefix.length); - - return cleanId; -} - -function getWorkspaceResourceTypeFromFullId(fullId: string): WorkspaceResourceType | undefined { - if (fullId.startsWith(WorkspaceResourceType.AttachedAccounts)) { - return WorkspaceResourceType.AttachedAccounts; - } else if (fullId.startsWith(WorkspaceResourceType.MongoClusters)) { - return WorkspaceResourceType.MongoClusters; - } - return undefined; -} diff --git a/src/tree/workspace-view/documentdb/AccountsItem.ts b/src/tree/workspace-view/documentdb/AccountsItem.ts deleted file mode 100644 index a22e3e573..000000000 --- a/src/tree/workspace-view/documentdb/AccountsItem.ts +++ /dev/null @@ -1,117 +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 * as l10n from '@vscode/l10n'; -import * as vscode from 'vscode'; -import { MongoClustersExperience, type Experience } from '../../../DocumentDBExperiences'; -import { StorageNames, StorageService } from '../../../services/storageService'; -import { generateDocumentDBStorageId } from '../../../utils/storageUtils'; // Import the new utility function -import { type AttachedClusterModel } from '../../documentdb/ClusterModel'; -import { type TreeElement } from '../../TreeElement'; -import { type TreeElementWithExperience } from '../../TreeElementWithExperience'; -import { WorkspaceResourceType } from '../../workspace-api/SharedWorkspaceResourceProvider'; -import { ClusterItem } from './ClusterItem'; -import { LocalEmulatorsItem } from './LocalEmulators/LocalEmulatorsItem'; -import { NewConnectionItem } from './NewConnectionItem'; - -export class AccountsItem implements TreeElement, TreeElementWithExperience { - public readonly id: string; - public readonly experience: Experience; - - constructor() { - this.id = `${WorkspaceResourceType.MongoClusters}`; - this.experience = MongoClustersExperience; - } - - async getChildren(): Promise { - const allItems = await StorageService.get(StorageNames.Workspace).getItems(WorkspaceResourceType.MongoClusters); - - // TODO: remove this in a couple of releases - // If we find any items that are not in the new storage format, - // we need to migrate them to stay consistent - const itemUpdates = new Map(); // original ID -> new ID - await Promise.allSettled( - allItems - // filter out emulators - .filter((item) => !item.properties?.isEmulator) - // only work on items in the old format - .filter((item) => !item.id.startsWith('storageId-')) - // convert them to the new format and return the modified items - .map(async (item) => { - try { - const originalId = item.id; - const connectionString = item.secrets?.[0]; - - if (!connectionString) { - console.warn(`Item ${originalId} has no connection string, skipping migration`); - return; - } - - const storageId = generateDocumentDBStorageId(connectionString); - - // Create the new item with updated ID - const newItem = { ...item, id: storageId }; - - // Save new item first for safety - await StorageService.get(StorageNames.Workspace).push( - WorkspaceResourceType.MongoClusters, - newItem, - true, - ); - - // Delete old item after successful save - await StorageService.get(StorageNames.Workspace).delete( - WorkspaceResourceType.MongoClusters, - originalId, - ); - - // Track this item for in-memory update - itemUpdates.set(originalId, storageId); - } catch (error) { - console.error(`Failed to migrate item ${item.id}`, error); - } - }), - ); - - // EXPLICIT SIDE EFFECT: Update the in-memory items to match storage changes - if (itemUpdates.size > 0) { - console.log(`Updating ${itemUpdates.size} in-memory items with new IDs`); - for (const item of allItems) { - const newId = itemUpdates.get(item.id); - if (newId) { - item.id = newId; // Explicit side effect, updating allItems in-memory - } - } - } - - return [ - new LocalEmulatorsItem(this.id), - ...allItems - .filter((item) => !item.properties?.isEmulator) // filter out emulators - .map((item) => { - const model: AttachedClusterModel = { - id: `${this.id}/${item.id}`, // To enable TreeView.reveal, we need to have a unique nested id - storageId: item.id, - name: item.name, - dbExperience: MongoClustersExperience, - connectionString: item?.secrets?.[0] ?? undefined, - }; - - return new ClusterItem(model); - }), - new NewConnectionItem(this.id), - ]; - } - - getTreeItem(): vscode.TreeItem { - return { - id: this.id, - contextValue: 'vscode.cosmosdb.workspace.mongoclusters.accounts', - label: l10n.t('MongoDB Accounts'), - iconPath: new vscode.ThemeIcon('link'), - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - }; - } -} diff --git a/src/tree/workspace-view/documentdb/ClusterItem.ts b/src/tree/workspace-view/documentdb/ClusterItem.ts deleted file mode 100644 index 95bc11680..000000000 --- a/src/tree/workspace-view/documentdb/ClusterItem.ts +++ /dev/null @@ -1,215 +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 { - AzureWizard, - callWithTelemetryAndErrorHandling, - nonNullProp, - nonNullValue, - UserCancelledError, - type IActionContext, -} from '@microsoft/vscode-azext-utils'; -import * as l10n from '@vscode/l10n'; -import * as vscode from 'vscode'; -import { ClustersClient } from '../../../documentdb/ClustersClient'; -import { CredentialCache } from '../../../documentdb/CredentialCache'; -import { type AuthenticateWizardContext } from '../../../documentdb/wizards/authenticate/AuthenticateWizardContext'; -import { ProvidePasswordStep } from '../../../documentdb/wizards/authenticate/ProvidePasswordStep'; -import { ProvideUserNameStep } from '../../../documentdb/wizards/authenticate/ProvideUsernameStep'; -import { ext } from '../../../extensionVariables'; -import { ClusterItemBase, type ClusterCredentials } from '../../documentdb/ClusterItemBase'; -import { type AttachedClusterModel } from '../../documentdb/ClusterModel'; -import { type TreeElementWithStorageId } from '../../TreeElementWithStorageId'; - -import { DocumentDBConnectionString } from '../../../documentdb/utils/DocumentDBConnectionString'; - -export class ClusterItem extends ClusterItemBase implements TreeElementWithStorageId { - public getCredentials(): Promise { - throw new Error('Method not implemented.'); - } - public override readonly cluster: AttachedClusterModel; - - constructor(mongoCluster: AttachedClusterModel) { - super(mongoCluster); - this.cluster = mongoCluster; // Store with correct type - } - - public get storageId(): string { - return this.cluster.storageId; - } - - public getConnectionString(): Promise { - return Promise.resolve(this.cluster.connectionString); - } - - /** - * Authenticates and connects to the MongoDB cluster. - * @param context The action context. - * @returns An instance of ClustersClient if successful; otherwise, null. - */ - protected async authenticateAndConnect(): Promise { - const result = await callWithTelemetryAndErrorHandling( - 'documentDB.mongoClusters.connect', - async (context: IActionContext) => { - context.telemetry.properties.view = 'workspace'; - - ext.outputChannel.appendLine( - l10n.t('Attempting to authenticate with {cluster}', { - cluster: this.cluster.name, - }), - ); - - let clustersClient: ClustersClient; - - const connectionString = new DocumentDBConnectionString(nonNullValue(this.cluster.connectionString)); - - let username: string | undefined = connectionString.username; - let password: string | undefined = connectionString.password; - - if (!username || username.length === 0 || !password || password.length === 0) { - const wizardContext: AuthenticateWizardContext = { - ...context, - adminUserName: undefined, - resourceName: this.cluster.name, - - // preconfigure the username in case it's provided connection string - selectedUserName: username, - // we'll always ask for the password - }; - - // Prompt the user for credentials using the extracted method - const credentialsProvided = await this.promptForCredentials(wizardContext); - - // If the wizard was aborted or failed, return null - if (!credentialsProvided) { - return null; - } - - context.valuesToMask.push(nonNullProp(wizardContext, 'password')); - - username = nonNullProp(wizardContext, 'selectedUserName'); - password = nonNullProp(wizardContext, 'password'); - } - - ext.outputChannel.append(l10n.t('Connecting to the cluster as "{username}"…', { username })); - - // Cache the credentials - CredentialCache.setCredentials( - this.id, - connectionString.toString(), - username, - password, - this.cluster.emulatorConfiguration, // workspace items can potentially be connecting to an emulator, so we always pass it - ); - - // Attempt to create the client with the provided credentials - try { - clustersClient = await ClustersClient.getClient(this.id).catch((error: Error) => { - ext.outputChannel.appendLine(l10n.t('failed.')); - ext.outputChannel.appendLine(l10n.t('Error: {error}', { error: error.message })); - - void vscode.window.showErrorMessage( - l10n.t('Failed to connect to "{cluster}"', { cluster: this.cluster.name }), - { - modal: true, - detail: - l10n.t('Revisit connection details and try again.') + - '\n\n' + - l10n.t('Error: {error}', { error: error.message }), - }, - ); - - throw error; - }); - } catch (error) { - console.error(error); - // If connection fails, remove cached credentials - await ClustersClient.deleteClient(this.id); - CredentialCache.deleteCredentials(this.id); - - // Return null to indicate failure - return null; - } - - ext.outputChannel.appendLine( - l10n.t('Connected to "{cluster}" as "{username}"', { - cluster: this.cluster.name, - username, - }), - ); - - return clustersClient; - }, - ); - return result ?? null; - } - - /** - * Prompts the user for credentials using a wizard. - * @param wizardContext The wizard context. - * @returns True if the wizard completed successfully; false if the user canceled or an error occurred. - */ - private async promptForCredentials(wizardContext: AuthenticateWizardContext): Promise { - const wizard = new AzureWizard(wizardContext, { - promptSteps: [new ProvideUserNameStep(), new ProvidePasswordStep()], - title: l10n.t('Authenticate to connect with your MongoDB cluster'), - showLoadingPrompt: true, - }); - - // Prompt the user for credentials - await callWithTelemetryAndErrorHandling('connect.promptForCredentials', async (context: IActionContext) => { - context.telemetry.properties.view = 'workspace'; - - context.errorHandling.rethrow = true; - context.errorHandling.suppressDisplay = false; - try { - await wizard.prompt(); // This will prompt the user; results are stored in wizardContext - } catch (error) { - if (error instanceof UserCancelledError) { - wizardContext.aborted = true; - } - } - }); - - // Return true if the wizard completed successfully; false otherwise - return !wizardContext.aborted; - } - - /** - * Returns the tree item representation of the cluster. - * @returns The TreeItem object. - */ - getTreeItem(): vscode.TreeItem { - let description: string | undefined = undefined; - let tooltipMessage: string | undefined = undefined; - - if (this.cluster.emulatorConfiguration?.isEmulator) { - // For emulator clusters, show TLS/SSL status if security is disabled - if (this.cluster.emulatorConfiguration?.disableEmulatorSecurity) { - description = l10n.t('⚠ TLS/SSL Disabled'); - tooltipMessage = l10n.t('⚠️ **Security:** TLS/SSL Disabled'); - } else { - tooltipMessage = l10n.t('✅ **Security:** TLS/SSL Enabled'); - } - } else { - // For non-emulator clusters, show SKU if defined - if (this.cluster.sku !== undefined) { - description = `(${this.cluster.sku})`; - } - } - - return { - id: this.id, - contextValue: this.contextValue, - label: this.cluster.name, - description: description, - iconPath: this.cluster.emulatorConfiguration?.isEmulator - ? new vscode.ThemeIcon('plug') - : new vscode.ThemeIcon('server-environment'), - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - tooltip: new vscode.MarkdownString(tooltipMessage), - }; - } -} diff --git a/src/tree/workspace-view/documentdb/ClustersWorkbenchBranchDataProvider.ts b/src/tree/workspace-view/documentdb/ClustersWorkbenchBranchDataProvider.ts deleted file mode 100644 index 58ad9c3c1..000000000 --- a/src/tree/workspace-view/documentdb/ClustersWorkbenchBranchDataProvider.ts +++ /dev/null @@ -1,34 +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 { type WorkspaceResource, type WorkspaceResourceBranchDataProvider } from '@microsoft/vscode-azureresources-api'; -import { ext } from '../../../extensionVariables'; -import { BaseCachedBranchDataProvider } from '../../BaseCachedBranchDataProvider'; -import { type TreeElement } from '../../TreeElement'; -import { AccountsItem } from './AccountsItem'; - -export class ClustersWorkspaceBranchDataProvider - extends BaseCachedBranchDataProvider - implements WorkspaceResourceBranchDataProvider -{ - protected get contextValue(): string { - return 'mongoVCore.workspace'; - } - - protected createResourceItem(_ontext: IActionContext, _resource?: WorkspaceResource): TreeElement | undefined { - return new AccountsItem(); - } - - protected onResourceItemRetrieved( - cachedItem: AccountsItem, - _resource?: WorkspaceResource, - _context?: IActionContext, - _fromCache?: boolean, - ): void { - // Workspace picker relies on this value - ext.mongoClusterWorkspaceBranchDataResource = cachedItem; - } -} diff --git a/src/tree/workspace-view/documentdb/LocalEmulators/LocalEmulatorsItem.ts b/src/tree/workspace-view/documentdb/LocalEmulators/LocalEmulatorsItem.ts deleted file mode 100644 index 5f56c9c00..000000000 --- a/src/tree/workspace-view/documentdb/LocalEmulators/LocalEmulatorsItem.ts +++ /dev/null @@ -1,68 +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 * as l10n from '@vscode/l10n'; -import * as vscode from 'vscode'; -import { getThemeAgnosticIconPath } from '../../../../constants'; -import { MongoClustersExperience } from '../../../../DocumentDBExperiences'; -import { StorageNames, StorageService } from '../../../../services/storageService'; -import { type EmulatorConfiguration } from '../../../../utils/emulatorConfiguration'; -import { type AttachedClusterModel } from '../../../documentdb/ClusterModel'; -import { type TreeElement } from '../../../TreeElement'; -import { type TreeElementWithContextValue } from '../../../TreeElementWithContextValue'; -import { WorkspaceResourceType } from '../../../workspace-api/SharedWorkspaceResourceProvider'; -import { ClusterItem } from '../ClusterItem'; -import { NewEmulatorConnectionItem } from './NewEmulatorConnectionItem'; - -export class LocalEmulatorsItem implements TreeElement, TreeElementWithContextValue { - public readonly id: string; - public readonly contextValue: string = 'treeItem.newConnection'; - - constructor(public readonly parentId: string) { - this.id = `${parentId}/localEmulators`; - } - - async getChildren(): Promise { - const allItems = await StorageService.get(StorageNames.Workspace).getItems(WorkspaceResourceType.MongoClusters); - const results = ( - await Promise.all( - allItems - .filter((item) => item.properties?.isEmulator) // only show emulators - .map(async (item) => { - const { id, name, properties, secrets } = item; - // we need to create the emulator configuration object from - // the flat properties object - const emulatorConfiguration: EmulatorConfiguration = { - isEmulator: true, - disableEmulatorSecurity: !!properties?.disableEmulatorSecurity, - }; - - const model: AttachedClusterModel = { - id: `${this.id}/${id}`, // To enable TreeView.reveal, we need to have a unique nested id - storageId: id, - name, - dbExperience: MongoClustersExperience, - connectionString: secrets?.[0], - emulatorConfiguration: emulatorConfiguration, - }; - - return new ClusterItem(model); - }), - ) - ).filter((item) => item !== undefined); // Explicitly filter out undefined values - - return [...results, new NewEmulatorConnectionItem(this.id)]; - } - - public getTreeItem(): vscode.TreeItem { - return { - id: this.id, - contextValue: this.contextValue, - label: l10n.t('Local Emulators'), - iconPath: getThemeAgnosticIconPath('CosmosDBAccount.svg'), - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - }; - } -} diff --git a/src/tree/workspace-view/documentdb/LocalEmulators/NewEmulatorConnectionItem.ts b/src/tree/workspace-view/documentdb/LocalEmulators/NewEmulatorConnectionItem.ts deleted file mode 100644 index 8ec0d3ed2..000000000 --- a/src/tree/workspace-view/documentdb/LocalEmulators/NewEmulatorConnectionItem.ts +++ /dev/null @@ -1,32 +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 * as l10n from '@vscode/l10n'; -import * as vscode from 'vscode'; -import { type TreeElement } from '../../../TreeElement'; -import { type TreeElementWithContextValue } from '../../../TreeElementWithContextValue'; - -export class NewEmulatorConnectionItem implements TreeElement, TreeElementWithContextValue { - public readonly id: string; - public readonly contextValue: string = 'treeItem.newConnection'; - - constructor(public readonly parentId: string) { - this.id = `${parentId}/newEmulatorConnection`; - } - - public getTreeItem(): vscode.TreeItem { - return { - id: this.id, - contextValue: this.contextValue, - label: l10n.t('New Emulator Connection…'), - iconPath: new vscode.ThemeIcon('plus'), - command: { - command: 'documentDB.newEmulatorConnection', - title: '', - arguments: [this], - }, - }; - } -} diff --git a/src/tree/workspace-view/documentdb/NewConnectionItem.ts b/src/tree/workspace-view/documentdb/NewConnectionItem.ts deleted file mode 100644 index afc9c9ab3..000000000 --- a/src/tree/workspace-view/documentdb/NewConnectionItem.ts +++ /dev/null @@ -1,32 +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 * as l10n from '@vscode/l10n'; -import * as vscode from 'vscode'; -import { type TreeElement } from '../../TreeElement'; -import { type TreeElementWithContextValue } from '../../TreeElementWithContextValue'; - -export class NewConnectionItem implements TreeElement, TreeElementWithContextValue { - public readonly id: string; - public readonly contextValue: string = 'treeItem.newConnection'; - - constructor(public readonly parentId: string) { - this.id = `${parentId}/newConnection`; - } - - public getTreeItem(): vscode.TreeItem { - return { - id: this.id, - contextValue: this.contextValue, - label: l10n.t('New Connection…'), - iconPath: new vscode.ThemeIcon('plus'), - command: { - command: 'documentDB.newConnection', - title: '', - arguments: [this], - }, - }; - } -} diff --git a/src/utils/CaseInsensitiveMap.ts b/src/utils/CaseInsensitiveMap.ts new file mode 100644 index 000000000..e3491da9c --- /dev/null +++ b/src/utils/CaseInsensitiveMap.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. + *--------------------------------------------------------------------------------------------*/ + +export class CaseInsensitiveMap extends Map { + set(key: string, value: V): this { + return super.set(key.toLowerCase(), value); + } + + get(key: string): V | undefined { + return super.get(key.toLowerCase()); + } + + has(key: string): boolean { + return super.has(key.toLowerCase()); + } + + delete(key: string): boolean { + return super.delete(key.toLowerCase()); + } +} diff --git a/src/utils/LazyMetadataLoader.ts b/src/utils/LazyMetadataLoader.ts new file mode 100644 index 000000000..0001f0e85 --- /dev/null +++ b/src/utils/LazyMetadataLoader.ts @@ -0,0 +1,244 @@ +/*--------------------------------------------------------------------------------------------- + * 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 AzureSubscription } from '@microsoft/vscode-azureresources-api'; + +import { CaseInsensitiveMap } from './CaseInsensitiveMap'; + +/** + * Configuration interface for the LazyMetadataLoader. + * + * @template TMetadata The type of metadata being cached (e.g., ClusterModel) + * @template TItem The type of resource items that will be updated (e.g., RUResourceItem, VCoreResourceItem) + */ +export interface LazyMetadataLoaderConfig { + /** Duration in milliseconds for how long the cache should remain valid */ + readonly cacheDuration: number; + + /** + * Function to load metadata from the Azure API. + * Should return a Map where keys are resource IDs and values are metadata objects. + */ + readonly loadMetadata: ( + subscription: AzureSubscription, + context: IActionContext, + ) => Promise>; + + /** + * Function to update a resource item with new metadata. + * This is called when the cache is refreshed and items need to be updated. + */ + readonly updateItem: (item: TItem, metadata: TMetadata | undefined) => void; + + /** + * Callback function to refresh the item in the tree view. + * This is typically the refresh method from the tree data provider. + */ + readonly refreshCallback: (item: TItem) => void; +} + +/** + * LazyMetadataLoader is a helper class that manages lazy loading of detailed metadata for Azure resources. + * + * ## Purpose + * This class exists to address a common pattern in tree data providers where: + * 1. Initial resource information comes from the Azure Resources extension (basic info only) + * 2. Detailed metadata needs to be loaded separately using dedicated Azure SDK clients + * 3. Resource items need to be updated once the detailed metadata becomes available + * 4. Cache needs to be managed with proper expiration and cleanup + * + * ## The Problem It Solves + * Without this helper, each BranchDataProvider would need to implement: + * - A flag to track if cache update has been requested + * - A cache to store the detailed metadata + * - A map to track items that need updating after cache refresh + * - Cache expiration logic with setTimeout + * - Coordination between cache loading and item updates + * + * ## How It Works + * 1. Caller checks `needsCacheUpdate` to determine if background loading should be triggered + * 2. Resource items are registered via `addItemForRefresh()` to be updated when metadata loads + * 3. When `loadCacheAndRefreshItems()` completes, all registered items are updated and refreshed + * 4. Cache automatically expires after the configured duration + * + * ## Usage Example + * ```typescript + * private readonly metadataLoader = new LazyMetadataLoader({ + * cacheDuration: 5 * 60 * 1000, // 5 minutes + * loadMetadata: async (subscription, context) => { + * const client = await createCosmosDBManagementClient(context, subscription); + * const accounts = await client.databaseAccounts.list(); + * const cache = new CaseInsensitiveMap(); + * accounts.forEach(account => cache.set(account.id!, transformToClusterModel(account))); + * return cache; + * }, + * updateItem: (item, metadata) => { + * item.cluster = { ...item.cluster, ...metadata }; + * }, + * refreshCallback: (item) => this.refresh(item), + * }); + * + * // In getResourceItem(): + * if (this.metadataLoader.needsCacheUpdate) { + * void this.metadataLoader.loadCacheAndRefreshItems(resource.subscription, context); + * } + * const metadata = this.metadataLoader.getCachedMetadata(resource.id); + * const item = new RUResourceItem(subscription, { ...resource, ...metadata }); + * this.metadataLoader.addItemForRefresh(resource.id, item); + * ``` + * + * @template TMetadata The type of metadata being cached + * @template TItem The type of resource items that will be updated + */ +export class LazyMetadataLoader { + private readonly config: LazyMetadataLoaderConfig; + + /** Flag to track if cache update is needed */ + private cacheUpdateNeeded = true; + + /** + * Cache for storing detailed metadata. + * Uses CaseInsensitiveMap to handle Azure resource ID casing inconsistencies. + */ + private readonly metadataCache = new CaseInsensitiveMap(); + + /** + * Map of items that need to be refreshed when cache loading completes. + * Uses CaseInsensitiveMap to handle Azure resource ID casing inconsistencies. + */ + private readonly itemsToRefresh = new CaseInsensitiveMap(); + + /** Timer ID for cache expiration */ + private cacheExpirationTimer: NodeJS.Timeout | undefined; + + constructor(config: LazyMetadataLoaderConfig) { + this.config = config; + } + + /** + * Gets cached metadata for a resource. + * Returns undefined if metadata is not yet available in cache. + * + * @param resourceId The Azure resource ID + * @returns The cached metadata if available, undefined otherwise + */ + getCachedMetadata(resourceId: string): TMetadata | undefined { + return this.metadataCache.get(resourceId); + } + + /** + * Adds a resource item to be refreshed when the cache loading completes. + * Items added here will have their metadata updated and UI refreshed once + * the background cache loading finishes. + * + * @param resourceId The Azure resource ID + * @param item The resource item that should be updated when metadata becomes available + */ + addItemForRefresh(resourceId: string, item: TItem): void { + this.itemsToRefresh.set(resourceId, item); + } + + /** + * Loads metadata from Azure and refreshes all registered items. + * This method should be called by the BranchDataProvider when it has access to + * subscription and context information. + * + * @param subscription The Azure subscription + * @param context The action context for telemetry and error handling + */ + async loadCacheAndRefreshItems(subscription: AzureSubscription, context: IActionContext): Promise { + try { + // Mark cache update as no longer needed to prevent multiple concurrent loads + this.cacheUpdateNeeded = false; + + // Load metadata using the provided function + const newMetadata = await this.config.loadMetadata(subscription, context); + + // Update the cache + this.metadataCache.clear(); + newMetadata.forEach((metadata, resourceId) => { + this.metadataCache.set(resourceId, metadata); + }); + + // Update and refresh all registered items + this.itemsToRefresh.forEach((item, resourceId) => { + const metadata = this.metadataCache.get(resourceId); + this.config.updateItem(item, metadata); + this.config.refreshCallback(item); + }); + + // Clear the items to refresh map + this.itemsToRefresh.clear(); + + // Set up cache expiration + this.setupCacheExpiration(); + } catch (error) { + console.error('Failed to load metadata cache:', error); + + // ensure we don't attempt to refresh again. + // This lazy metadata support is non-essential, so we can safely ignore errors and ignore the results. + this.cacheUpdateNeeded = false; + throw error; + } + } + + /** + * Clears all cached data and resets the loader state. + * Useful when subscription changes or manual cache invalidation is needed. + */ + clearCache(): void { + this.metadataCache.clear(); + this.itemsToRefresh.clear(); + this.cacheUpdateNeeded = true; + + if (this.cacheExpirationTimer) { + clearTimeout(this.cacheExpirationTimer); + this.cacheExpirationTimer = undefined; + } + } + + /** + * Sets up automatic cache expiration using setTimeout. + */ + private setupCacheExpiration(): void { + // Clear any existing timer + if (this.cacheExpirationTimer) { + clearTimeout(this.cacheExpirationTimer); + } + + // Set up new expiration timer + this.cacheExpirationTimer = setTimeout(() => { + this.metadataCache.clear(); + this.cacheUpdateNeeded = true; + this.cacheExpirationTimer = undefined; + console.debug('Metadata cache expired and cleared'); + }, this.config.cacheDuration); + } + + /** + * Checks if cache loading is needed. + * Useful for conditional logic in the calling code. + */ + get needsCacheUpdate(): boolean { + return this.cacheUpdateNeeded; + } + + /** + * Gets the current size of the metadata cache. + * Useful for debugging and telemetry. + */ + get cacheSize(): number { + return this.metadataCache.size; + } + + /** + * Disposes the loader and cleans up resources. + * Should be called when the loader is no longer needed. + */ + dispose(): void { + this.clearCache(); + } +} diff --git a/src/utils/emulatorUtils.ts b/src/utils/emulatorUtils.ts index 69cb40c12..fb01b46fb 100644 --- a/src/utils/emulatorUtils.ts +++ b/src/utils/emulatorUtils.ts @@ -31,7 +31,7 @@ export function getEmulatorItemLabelForApi(api: API, port: string | number | und const experience = getExperienceFromApi(api); let label = l10n.t('{experienceName} Emulator', { experienceName: experience.shortName }); - if (experience.api === API.MongoDB || experience.api === API.MongoClusters) { + if (experience.api === API.CosmosDBMongoRU || experience.api === API.DocumentDB) { label = l10n.t('MongoDB Emulator'); } diff --git a/src/utils/nonNull.ts b/src/utils/nonNull.ts index fc40ecab1..0a1f0cf94 100644 --- a/src/utils/nonNull.ts +++ b/src/utils/nonNull.ts @@ -6,29 +6,65 @@ import * as l10n from '@vscode/l10n'; /** - * Retrieves a property by name from an object and checks that it's not null and not undefined. It is strongly typed - * for the property and will give a compile error if the given name is not a property of the source. + * NOTE: These helpers append a short context string to thrown errors to help with triage. + * + * Parameter guidelines: + * - `message` (required): A short, human-friendly identifier for the value being checked. + * Use the actual member access or assignment LHS from your code: + * - Member access: 'selectedItem.cluster.connectionString' + * - Wizard context property: 'wizardContext.password' + * - Local variable or expression: 'connectionString.match(...)' + * + * - `details` (required): A short file identifier using the actual file base name from your code. + * Since this is an open source project, use real file names like 'ExecuteStep.ts', 'ConnectionItem.ts', etc. + * Keep it short and inline (do not create a constant). + * + * Example usage with actual code references: + * nonNullProp(selectedItem.cluster, 'connectionString', 'selectedItem.cluster.connectionString', 'ExecuteStep.ts') + */ + +/** + * Retrieves a property by name from an object and asserts it's not null or undefined. + * Provides compile-time type checking for the property name. + * + * @param sourceObj - The object to read the property from + * @param name - The property name (compile-time checked) + * @param message - Short identifier describing the checked value (prefer member-access or LHS) + * @param details - Short file identifier (file base name) used to help triage runtime errors + * @returns The non-null property value + * @throws Error with message format: ", (details)" when value is missing */ export function nonNullProp( - source: TSource, + sourceObj: TSource, name: TKey, - message?: string, + message: string, + details: string, ): NonNullable { - const value: NonNullable = >source[name]; - if (message) { - return nonNullValue(value, `${name}, ${message}`); - } - return nonNullValue(value, name); + const value: NonNullable = >sourceObj[name]; + return nonNullValue(value, `${name}, ${message}`, details); } /** - * Validates that a given value is not null and not undefined. + * Validates that a given value is not null or undefined. + * + * @param value - The value to check + * @param propertyNameOrMessage - Property name or short human message describing the value + * (prefer member-access or the LHS of the assignment) + * @param details - Short file identifier (file base name) used to assist with triage + * @returns The validated non-null value + * @throws Error when value is null or undefined + * + * @example + * ```typescript + * nonNullValue(someVar, 'connectionString', 'ExecuteStep.ts') + * ``` */ -export function nonNullValue(value: T | undefined | null, propertyNameOrMessage?: string): T { +export function nonNullValue(value: T | undefined | null, propertyNameOrMessage: string, details: string): T { if (value === undefined || value === null) { throw new Error( l10n.t('Internal error: Expected value to be neither null nor undefined') + - (propertyNameOrMessage ? `: ${propertyNameOrMessage}` : ''), + (propertyNameOrMessage ? `: ${propertyNameOrMessage}` : '') + + (details ? ` (${details})` : ''), ); } @@ -36,13 +72,20 @@ export function nonNullValue(value: T | undefined | null, propertyNameOrMessa } /** - * Validates that a given string is not null, undefined, nor empty + * Validates that a given string is not null, undefined, or empty. + * + * @param value - The string to check + * @param propertyNameOrMessage - Property name or message describing the value (e.g. 'database') + * @param details - Short file identifier (file base name) to help triage issues + * @returns The validated non-empty string + * @throws Error when value is null, undefined, or empty string */ -export function nonNullOrEmptyValue(value: string | undefined, propertyNameOrMessage?: string): string { +export function nonNullOrEmptyValue(value: string | undefined, propertyNameOrMessage: string, details: string): string { if (!value) { throw new Error( l10n.t('Internal error: Expected value to be neither null, undefined, nor empty') + - (propertyNameOrMessage ? `: ${propertyNameOrMessage}` : ''), + (propertyNameOrMessage ? `: ${propertyNameOrMessage}` : '') + + (details ? ` (${details})` : ''), ); } diff --git a/src/utils/pickItem/pickAppResource.ts b/src/utils/pickItem/pickAppResource.ts deleted file mode 100644 index 473eb4c41..000000000 --- a/src/utils/pickItem/pickAppResource.ts +++ /dev/null @@ -1,128 +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 { - azureResourceExperience, - type ContextValueFilter, - type ITreeItemPickerContext, -} from '@microsoft/vscode-azext-utils'; -import { type AzExtResourceType } from '@microsoft/vscode-azureresources-api'; -import type * as vscode from 'vscode'; -import { ext } from '../../extensionVariables'; -import { type TreeElement } from '../../tree/TreeElement'; -import { WorkspaceResourceType } from '../../tree/workspace-api/SharedWorkspaceResourceProvider'; - -export interface PickAppResourceOptions { - type?: AzExtResourceType | AzExtResourceType[]; - expectedChildContextValue?: string | RegExp | (string | RegExp)[]; - unexpectedContextValue?: string | RegExp | (string | RegExp)[]; -} - -export interface PickWorkspaceResourceOptions { - type: WorkspaceResourceType | WorkspaceResourceType[]; - expectedChildContextValue?: string | RegExp | (string | RegExp)[]; -} - -export async function pickAppResource( - context: ITreeItemPickerContext, - options?: PickAppResourceOptions, -): Promise { - let filter: ContextValueFilter | undefined = undefined; - if (options?.expectedChildContextValue) { - filter ??= { include: options.expectedChildContextValue }; - - // Only if expectedChildContextValue is set, we will exclude unexpectedContextValue - if (options?.unexpectedContextValue) { - filter.exclude = options.unexpectedContextValue; - } - } - - return await azureResourceExperience( - context, - ext.rgApiV2.resources.azureResourceTreeDataProvider, - options?.type ? (Array.isArray(options.type) ? options.type : [options.type]) : undefined, - filter, - ); -} - -const isPick = (node: vscode.TreeItem, contextValueFilter?: ContextValueFilter): boolean => { - if (!contextValueFilter) { - return true; - } - - const includeOption = contextValueFilter.include; - const excludeOption = contextValueFilter.exclude; - - const includeArray: (string | RegExp)[] = Array.isArray(includeOption) ? includeOption : [includeOption]; - const excludeArray: (string | RegExp)[] = excludeOption - ? Array.isArray(excludeOption) - ? excludeOption - : [excludeOption] - : []; - - const nodeContextValues: string[] = node.contextValue?.split(';') ?? []; - const matchesSingleFilter = (matcher: string | RegExp, nodeContextValues: string[]) => { - return nodeContextValues.some((c) => { - if (matcher instanceof RegExp) { - return matcher.test(c); - } - - // Context value matcher is a string, do full equality (same as old behavior) - return c === matcher; - }); - }; - - return ( - includeArray.some((i) => matchesSingleFilter(i, nodeContextValues)) && - !excludeArray.some((e) => matchesSingleFilter(e, nodeContextValues)) - ); -}; - -export async function pickWorkspaceResource( - context: ITreeItemPickerContext, - options?: PickWorkspaceResourceOptions, -): Promise { - options ??= { - type: [WorkspaceResourceType.AttachedAccounts, WorkspaceResourceType.MongoClusters], - expectedChildContextValue: ['treeItem.account', 'treeItem.mongoCluster'], - }; - - const types = Array.isArray(options.type) ? options.type : [options.type]; - const contextValueFilter = options?.expectedChildContextValue - ? { include: options.expectedChildContextValue } - : undefined; - - const firstWorkspaceResources = types - .map((type) => { - if (type === WorkspaceResourceType.MongoClusters) { - return ext.mongoClusterWorkspaceBranchDataResource; - } - - return undefined; - }) - .filter((resource) => resource !== undefined); - - const childrenPromise = await Promise.allSettled(firstWorkspaceResources.map((item) => item?.getChildren())); - const items = childrenPromise.map((promise) => (promise.status === 'fulfilled' ? promise.value : [])).flat(); - const quickPickItemsPromise = await Promise.allSettled( - items.map(async (item) => [await item.getTreeItem(), item] as const), - ); - const quickPickItems = quickPickItemsPromise - .map((promise) => (promise.status === 'fulfilled' ? promise.value : undefined)) - .filter((item) => item !== undefined) - .filter(([treeItem]) => isPick(treeItem, contextValueFilter)) - .map(([treeItem, item]) => { - return { - label: ((treeItem.label as vscode.TreeItemLabel)?.label || treeItem.label) as string, - description: treeItem.description as string, - data: item, - }; - }); - - const pickedItem = await context.ui.showQuickPick(quickPickItems, {}); - const node = pickedItem.data; - - return node as T; -} diff --git a/src/utils/workspacUtils.ts b/src/utils/workspacUtils.ts index ab0ceb885..5141e904f 100644 --- a/src/utils/workspacUtils.ts +++ b/src/utils/workspacUtils.ts @@ -16,5 +16,9 @@ export function getRootPath(): string | undefined { export function getBatchSizeSetting(): number { const config: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration(); - return nonNullValue(config.get(ext.settingsKeys.batchSize), 'batchSize'); + return nonNullValue( + config.get(ext.settingsKeys.batchSize), + 'config.get(ext.settingsKeys.batchSize)', + 'workspacUtils.ts', + ); } diff --git a/src/vscodeUriHandler.ts b/src/vscodeUriHandler.ts index c5ed18126..b7420115b 100644 --- a/src/vscodeUriHandler.ts +++ b/src/vscodeUriHandler.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { callWithTelemetryAndErrorHandling, nonNullValue, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; import { openCollectionViewInternal } from './commands/openCollectionView/openCollectionView'; @@ -16,6 +16,7 @@ import { revealInConnectionsView, waitForConnectionsViewReady, } from './tree/connections-view/connectionsViewHelpers'; +import { nonNullValue } from './utils/nonNull'; import { generateDocumentDBStorageId } from './utils/storageUtils'; // #region Type Definitions @@ -170,7 +171,7 @@ async function handleConnectionStringRequest( name: newConnectionLabel, // Connection strings handled by this handler are MongoDB-style, so mark the API accordingly. properties: { - api: API.MongoDB, + api: API.DocumentDB, emulatorConfiguration: { isEmulator, disableEmulatorSecurity: !!disableEmulatorSecurity }, availableAuthMethods: [], }, @@ -450,7 +451,7 @@ async function openDedicatedView( return openCollectionViewInternal(context, { clusterId: clusterId, - databaseName: nonNullValue(database, 'database'), - collectionName: nonNullValue(collection, 'collection'), + databaseName: nonNullValue(database, 'database', 'vscodeUriHandler.ts'), + collectionName: nonNullValue(collection, 'collection', 'vscodeUriHandler.ts'), }); } diff --git a/src/webviews/documentdb/collectionView/collectionViewController.ts b/src/webviews/documentdb/collectionView/collectionViewController.ts index 0f8484e42..5c25d2c73 100644 --- a/src/webviews/documentdb/collectionView/collectionViewController.ts +++ b/src/webviews/documentdb/collectionView/collectionViewController.ts @@ -22,10 +22,10 @@ export class CollectionViewController extends WebviewController