diff --git a/.github/skills/review-external-pr/SKILL.md b/.github/skills/review-external-pr/SKILL.md new file mode 100644 index 000000000..6a4cc134b --- /dev/null +++ b/.github/skills/review-external-pr/SKILL.md @@ -0,0 +1,101 @@ +--- +name: review-external-pr +description: Prepare an external contributor's PR for maintainer review by redirecting it into a dedicated review branch, then merging and creating a new finalization PR targeting next. Use when triaging/reviewing contributor PRs, merging external PRs with maintainer changes, or setting up a review workflow for incoming community contributions. +--- + +# Review External PR Workflow + +Redirects an external contributor's PR into a `reviews/` staging branch so a maintainer can inspect, add changes, then merge everything into `next` cleanly. + +## When to Use + +- An external contributor opened a PR targeting `next` and you want to add changes before merging +- You want to formally review and finalize a community contribution +- You want the contributor to get proper merge credit while still controlling what lands in `next` + +## Workflow Steps + +### 1. Gather PR Info + +```bash +gh pr view --json title,author,headRefName,baseRefName,body +``` + +Note the **PR number**, **title**, and **author login** — you'll need them for branch naming and PR descriptions. + +### 2. Create the Review Branch + +Branch naming format: `reviews/-original-pr-` + +```bash +git fetch origin +git checkout -b reviews/-original-pr- origin/next +git push origin reviews/-original-pr- +``` + +Example: `reviews/copy-reference-original-pr-545` + +### 3. Retarget the Contributor's PR + +> ⚠️ **Known issue**: `gh pr edit --base` may emit a deprecation warning about Projects (classic). This is a cosmetic warning only — the base branch change succeeds regardless. Verify with `gh pr view --json baseRefName`. + +```bash +gh pr edit --base reviews/-original-pr- +``` + +Verify: + +```bash +gh pr view --json baseRefName +``` + +### 4. Merge the Contributor's PR + +Once the base is updated and the PR is ready: + +```bash +gh pr merge --squash +``` + +Or approve + merge via the GitHub UI to trigger any required status checks. + +### 5. Create the Finalization PR + +Pull the merged review branch, then open a new PR from it to `next`: + +```bash +git checkout reviews/-original-pr- +git pull origin reviews/-original-pr- +``` + +Create the PR: + +```bash +gh pr create \ + --base next \ + --head reviews/-original-pr- \ + --title " [reviewed]" \ + --body "This PR finalizes the review of the contribution originally submitted by @ in #. + +Original PR: " +``` + +### 6. Comment on the Original PR + +Go back to the contributor's original (now merged) PR and leave a comment linking to the finalization PR: + +```bash +gh pr comment \ + --body "Thank you for the contribution! The review is continuing in # where maintainer changes will be finalized before merging to \`next\`." +``` + +## Summary + +| Step | Action | Result | +| ---- | ------------------------------------------ | ----------------------------------------- | +| 1 | Gather PR info | Know PR number, title, author | +| 2 | Create `reviews/...` branch off `next` | Staging branch ready | +| 3 | Retarget contributor's PR to review branch | Their diff is scoped to review branch | +| 4 | Merge contributor's PR | Contributor gets merge credit | +| 5 | Create finalization PR to `next` | Maintainer controls what lands in `next` | +| 6 | Comment on original PR with link to new PR | Contributor is informed, thread is linked | diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d4160eb..e5c6a89b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Change Log +## 0.7.4 + +### New Features & Improvements + +- **URL-Encoded Password Detection**: When a connection attempt fails and the password contains URL-encoded characters, the extension now offers a "Retry with Decoded Password" option. If the retry succeeds, the decoded password can be saved. [#444](https://github.com/microsoft/vscode-documentdb/issues/444), [#594](https://github.com/microsoft/vscode-documentdb/pull/594) +- **Rich Markdown Tooltips**: Cluster, database, and collection tree items in the Connections view now show rich markdown tooltips on hover, displaying useful details (host, auth method, document count, storage size) without requiring expansion or connection. [#579](https://github.com/microsoft/vscode-documentdb/issues/579), [#588](https://github.com/microsoft/vscode-documentdb/pull/588) +- **Copy Reference Context Menu**: Adds a "Copy Reference…" right-click option to database, collection, and index nodes with a QuickPick format picker. Databases offer name, shell command (`use dbName`), or qualified name; collections offer name, namespace, shell reference, or `db.getCollection()` form; indexes offer name, key definition, or shell command. Names with special characters automatically use safe escaping. [#545](https://github.com/microsoft/vscode-documentdb/pull/545), [#587](https://github.com/microsoft/vscode-documentdb/pull/587) + +### Documentation + +- **Improved CONTRIBUTING.md**: Adds a PR submission checklist, corrects Node/npm version requirements, and adds multi-platform setup stubs. [#565](https://github.com/microsoft/vscode-documentdb/pull/565) + +### Dependencies + +- **Dependency Updates**: Bumps `handlebars` (4.7.8 → 4.7.9), `lodash` (4.17.23 → 4.18.1), `lodash` and `@microsoft/api-extractor` in `/api`, and `follow-redirects` (1.15.11 → 1.16.0). [#552](https://github.com/microsoft/vscode-documentdb/pull/552), [#556](https://github.com/microsoft/vscode-documentdb/pull/556), [#558](https://github.com/microsoft/vscode-documentdb/pull/558), [#586](https://github.com/microsoft/vscode-documentdb/pull/586) + ## 0.7.3 ### New Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eab16a554..b306cd46b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,11 +2,12 @@ Thank you for your interest in contributing to the **DocumentDB for VS Code** extension. This guide helps you set up your development environment and configure Visual Studio Code to effectively contribute to the extension. -The document consists of three sections: +The document consists of four sections: 1. [Branching Strategy](#1-branching-strategy) 2. [Machine Setup](#2-machine-setup) 3. [VS Code Configuration](#3-vs-code-configuration) +4. [PR Submission Checklist](#4-pr-submission-checklist) ## 1. Branching Strategy @@ -22,12 +23,10 @@ The repository follows a structured branching strategy to ensure smooth developm GitHub Actions are configured to perform automated checks on the repository. The intensity of these checks depends on the target branch: 1. **Push to `next`, `dev/*`, or `feature/*` branches**: - - Runs basic code quality checks and tests. - Skips resource-intensive jobs like integration tests and packaging to focus on code validation. 2. **Pull Requests to `main` or `next`**: - - Executes all jobs, including code checks, tests, and packaging. - Ensures complete validation before merging, including artifact generation. @@ -38,18 +37,21 @@ This setup ensures that contributions are thoroughly validated while optimizing ## 2. Machine Setup -Follow these instructions to configure your machine for JavaScript/TypeScript development using Windows Subsystem for Linux (WSL2) and Visual Studio Code. +> **Platform coverage:** The detailed setup instructions below are written for **Windows + WSL2**. Stub sections for [macOS](#22-macos-pending), [Windows (native)](#23-windows-native-pending), and [plain Linux](#24-linux-pending) are included but not yet filled in; Contributors on those platforms are warmly invited to submit a PR expanding those sections! -> This setup assumes you're using WSL2 on Windows. However, you can use a Linux or Windows setup exclusively if preferred. +--- -### 2.1. Install Ubuntu 22.\* on Windows +### 2.1. Windows + WSL2 _(documented)_ -- Install **Ubuntu 22.\*** from the Microsoft Store and launch it to configure your Linux user account. +Follow these instructions to configure your machine for JavaScript/TypeScript development using Windows Subsystem for Linux (WSL2) and Visual Studio Code. + +#### 2.1.1. Install Ubuntu 22.\* on Windows +- Install **Ubuntu 22.\*** from the Microsoft Store and launch it to configure your Linux user account. - Your development environment and tools will reside within `WSL2`. - VS Code integrates seamlessly with `WSL2` instances, enabling smooth development from your Windows machine. -### 2.2. Update Ubuntu Packages +#### 2.1.2. Update Ubuntu Packages Open your Ubuntu terminal and run: @@ -58,29 +60,48 @@ sudo apt update sudo apt upgrade ``` -### 2.3. Install Node.js with FNM (Fast Node Manager) +#### 2.1.3. Install Node.js with FNM (Fast Node Manager) + +`FNM` helps with installing and switching Node.js versions easily. This is useful for testing compatibility across different Node.js versions. -- `FNM` helps with installing and switching Node.js versions easily. This is useful for testing compatibility across different Node.js versions. +The minimum required versions are **Node.js 22.18.0** and **npm 10.0.0** (see `engines` in `package.json`). Run the following commands: ```bash curl -fsSL https://fnm.vercel.app/install | bash source ~/.bashrc -fnm install 22 -fnm use 22 -fnm default 22 -node --version +fnm install 22.18.0 +fnm use 22.18.0 +fnm default 22.18.0 +node --version # should print v22.18.0 or later +npm --version # should print 10.x or later ``` -### 2.4. Install TypeScript Globally (optional) - -You can install TypeScript globally: +#### 2.1.4. Install TypeScript Globally (optional) ```bash npm install -g typescript ``` +--- + +### 2.2. macOS _(pending)_ + +> **Help wanted!** If you develop on macOS, please consider contributing setup instructions for this section. The general flow (install Node.js via a version manager such as `nvm` or `fnm`, clone the repo, `npm install && npm run build`) should be very similar to the WSL2 path above. + +--- + +### 2.3. Windows (native) _(pending)_ + +> **Help wanted!** If you develop on Windows without WSL2, please consider contributing setup instructions for this section. + +--- + +### 2.4. Linux _(pending)_ + +> **Help wanted!** If you develop on Linux natively, please consider contributing setup instructions for this section. The WSL2 Ubuntu steps above should translate almost verbatim. + ## 3. VS Code Configuration This section explains how to clone the **DocumentDB for VS Code** repository and set up Visual Studio Code for development and debugging. @@ -90,7 +111,6 @@ This section explains how to clone the **DocumentDB for VS Code** repository and 1. Ensure you have completed the [Machine Setup](#2-machine-setup) steps. 2. Fork or directly clone the official repository: - - [DocumentDB for VS Code (vscode-documentdb)](https://github.com/microsoft/vscode-documentdb) - Open your **WSL2** terminal and clone the repository: @@ -124,6 +144,59 @@ code . - Select `Launch Extension (webpack)`. - Press `F5`. +## 4. PR Submission Checklist + +Before opening or marking a pull request as ready for review, **all of the following steps must pass locally**. The same checks run in CI, so catching failures locally saves time. + +### 4.1. Localization + +If you added, changed, or removed any user-facing string (anything passed to `vscode.l10n.t()`), regenerate the localization bundle: + +```bash +npm run l10n +``` + +Commit any changes to the `l10n/` folder together with your code changes. + +### 4.2. Formatting + +Run Prettier to ensure all files meet the project's formatting standards: + +```bash +npm run prettier-fix +``` + +Commit any files that Prettier reformats. + +### 4.3. Linting + +Run ESLint and fix all reported issues before submitting: + +```bash +npm run lint +``` + +### 4.4. Package Verification + +Verify the extension can be packaged successfully without errors: + +```bash +npm run package +``` + +This step catches webpack bundling issues and missing assets that unit tests alone won't surface. + +--- + +> **Summary — run these four commands before every PR:** +> +> ```bash +> npm run l10n +> npm run prettier-fix +> npm run lint +> npm run package +> ``` + ## You're Ready to Contribute! 🎉 You've now successfully set up your development environment and are ready to contribute to **DocumentDB for VS Code**. We appreciate your contributions! diff --git a/api/package-lock.json b/api/package-lock.json index 411708722..a31fe609c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -28,29 +28,6 @@ "license": "MIT", "peer": true }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -98,54 +75,41 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.52.8", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.52.8.tgz", - "integrity": "sha512-cszYIcjiNscDoMB1CIKZ3My61+JOhpERGlGr54i6bocvGLrcL/wo9o+RNXMBrb7XgLtKaizZWUpqRduQuHQLdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@microsoft/api-extractor-model": "7.30.6", - "@microsoft/tsdoc": "~0.15.1", - "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.13.1", - "@rushstack/rig-package": "0.5.3", - "@rushstack/terminal": "0.15.3", - "@rushstack/ts-command-line": "5.0.1", - "lodash": "~4.17.15", - "minimatch": "~3.0.3", + "version": "7.58.1", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.1.tgz", + "integrity": "sha512-kF3GFME4lN22O5zbnXk2RP4y/4PDQdps0xKiYTipMYprkwCmmpsWLZt/N2Fkbil540cSLfJX0BW7LkHzgMVUYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/api-extractor-model": "7.33.5", + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.21.0", + "@rushstack/rig-package": "0.7.2", + "@rushstack/terminal": "0.22.4", + "@rushstack/ts-command-line": "5.3.4", + "diff": "~8.0.2", + "lodash": "~4.18.1", + "minimatch": "10.2.3", "resolve": "~1.22.1", "semver": "~7.5.4", "source-map": "~0.6.1", - "typescript": "5.8.2" + "typescript": "5.9.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "node_modules/@microsoft/api-extractor-model": { - "version": "7.30.6", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.30.6.tgz", - "integrity": "sha512-znmFn69wf/AIrwHya3fxX6uB5etSIn6vg4Q4RB/tb5VDDs1rqREc+AvMC/p19MUN13CZ7+V/8pkYPTj7q8tftg==", + "version": "7.33.5", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.5.tgz", + "integrity": "sha512-Xh4dXuusndVQqVz4nEN9xOp0DyzsKxeD2FFJkSPg4arAjDSKPcy6cAc7CaeBPA7kF2wV1fuDlo2p/bNMpVr8yg==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "~0.15.1", - "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.13.1" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.21.0" } }, "node_modules/@microsoft/applicationinsights-channel-js": { @@ -238,21 +202,21 @@ } }, "node_modules/@microsoft/tsdoc": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", - "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "dev": true, "license": "MIT" }, "node_modules/@microsoft/tsdoc-config": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.17.1.tgz", - "integrity": "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz", + "integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "0.15.1", - "ajv": "~8.12.0", + "@microsoft/tsdoc": "0.16.0", + "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } @@ -306,13 +270,13 @@ "license": "MIT" }, "node_modules/@rushstack/node-core-library": { - "version": "5.13.1", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.13.1.tgz", - "integrity": "sha512-5yXhzPFGEkVc9Fu92wsNJ9jlvdwz4RNb2bMso+/+TH0nMm1jDDDsOIf4l8GAkPxGuwPw5DH24RliWVfSPhlW/Q==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.21.0.tgz", + "integrity": "sha512-LFzN+1lyWROit/P8Md6yxAth7lLYKn37oCKJHirEE2TQB25NDUM7bALf0ar+JAtwFfRCH+D+DGOA7DAzIi2r+g==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "~8.13.0", + "ajv": "~8.18.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", @@ -330,27 +294,25 @@ } } }, - "node_modules/@rushstack/node-core-library/node_modules/ajv": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", - "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", + "node_modules/@rushstack/problem-matcher": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.2.1.tgz", + "integrity": "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==", "dev": true, "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.4.1" + "peerDependencies": { + "@types/node": "*" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@rushstack/rig-package": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.5.3.tgz", - "integrity": "sha512-olzSSjYrvCNxUFZowevC3uz8gvKr3WTpHQ7BkpjtRpA3wK+T0ybep/SRUMfr195gBzJm5gaXw0ZMgjIyHqJUow==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.2.tgz", + "integrity": "sha512-9XbFWuqMYcHUso4mnETfhGVUSaADBRj6HUAAEYk50nMPn8WRICmBuCphycQGNB3duIR6EEZX3Xj3SYc2XiP+9A==", "dev": true, "license": "MIT", "dependencies": { @@ -359,13 +321,14 @@ } }, "node_modules/@rushstack/terminal": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.15.3.tgz", - "integrity": "sha512-DGJ0B2Vm69468kZCJkPj3AH5nN+nR9SPmC0rFHtzsS4lBQ7/dgOwtwVxYP7W9JPDMuRBkJ4KHmWKr036eJsj9g==", + "version": "0.22.4", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.22.4.tgz", + "integrity": "sha512-fhtLjnXCc/4WleVbVl6aoc7jcWnU6yqjS1S8WoaNREG3ycu/viZ9R/9QM7Y/b4CDvcXoiDyMNIay7JMwBptM3g==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/node-core-library": "5.13.1", + "@rushstack/node-core-library": "5.21.0", + "@rushstack/problem-matcher": "0.2.1", "supports-color": "~8.1.1" }, "peerDependencies": { @@ -378,13 +341,13 @@ } }, "node_modules/@rushstack/ts-command-line": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.0.1.tgz", - "integrity": "sha512-bsbUucn41UXrQK7wgM8CNM/jagBytEyJqXw/umtI8d68vFm1Jwxh1OtLrlW7uGZgjCWiiPH6ooUNa1aVsuVr3Q==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.4.tgz", + "integrity": "sha512-MLkVKVEN6/2clKTrjN2B2KqKCuPxRwnNsWY7a+FCAq2EMdkj10cM8YgiBSMeGFfzM0mDMzargpHNnNzaBi9Whg==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/terminal": "0.15.3", + "@rushstack/terminal": "0.22.4", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" @@ -444,16 +407,16 @@ } }, "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -530,21 +493,26 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/color-convert": { @@ -574,13 +542,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -613,6 +574,16 @@ "node": ">=0.10.0" } }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/discontinuous-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", @@ -720,6 +691,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -738,9 +726,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "dev": true, "license": "MIT", "dependencies": { @@ -786,22 +774,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -957,9 +929,9 @@ "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { @@ -970,9 +942,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -987,16 +959,19 @@ } }, "node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -1104,16 +1079,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/railroad-diagrams": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", @@ -1146,13 +1111,13 @@ } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -1463,9 +1428,9 @@ "peer": true }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1493,16 +1458,6 @@ "node": ">= 10.0.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", diff --git a/docs/index.md b/docs/index.md index 113de0914..47dfbbb2f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -65,7 +65,7 @@ The User Manual provides guidance on using DocumentDB for VS Code. It contains d 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.7](./release-notes/0.7), [0.7.2](./release-notes/0.7#patch-release-v072), [0.7.3](./release-notes/0.7#patch-release-v073) +- [0.7](./release-notes/0.7), [0.7.2](./release-notes/0.7#patch-release-v072), [0.7.3](./release-notes/0.7#patch-release-v073), [0.7.4](./release-notes/0.7#patch-release-v074) - [0.6](./release-notes/0.6), [0.6.1](./release-notes/0.6#patch-release-v061), [0.6.2](./release-notes/0.6#patch-release-v062), [0.6.3](./release-notes/0.6#patch-release-v063) - [0.5](./release-notes/0.5), [0.5.1](./release-notes/0.5#patch-release-v051), [0.5.2](./release-notes/0.5#patch-release-v052) - [0.4](./release-notes/0.4), [0.4.1](./release-notes/0.4#patch-release-v041) diff --git a/docs/release-notes/0.7.md b/docs/release-notes/0.7.md index 1ebe3d2d6..5aeb3746f 100644 --- a/docs/release-notes/0.7.md +++ b/docs/release-notes/0.7.md @@ -270,3 +270,52 @@ Internal telemetry for connection and discovery actions has been improved to cap See the full changelog entry for this release: ➡️ [CHANGELOG.md#073](https://github.com/microsoft/vscode-documentdb/blob/main/CHANGELOG.md#073) + +--- + +## Patch Release v0.7.4 + +This patch adds URL-encoded password detection, rich markdown tooltips across all tree item levels, a new "Copy Reference" context menu, and several dependency updates. + +### What's Changed in v0.7.4 + +#### 💠 **URL-Encoded Password Detection** ([#444](https://github.com/microsoft/vscode-documentdb/issues/444), [#594](https://github.com/microsoft/vscode-documentdb/pull/594)) + +Connecting with a URL-encoded password no longer results in a cryptic authentication failure. When a connection attempt fails and the password contains URL-encoded characters (`%XX` sequences), the extension now offers a **"Retry with Decoded Password"** option in the error dialog. If the retry succeeds, the decoded password can be saved — no manual editing required. + +Thanks to **[@vaibh123540](https://github.com/vaibh123540)** for the original contribution in [#454](https://github.com/microsoft/vscode-documentdb/pull/454)! + +#### 💠 **Rich Markdown Tooltips** ([#579](https://github.com/microsoft/vscode-documentdb/issues/579), [#588](https://github.com/microsoft/vscode-documentdb/pull/588)) + +Hovering over tree items in the Connections view now reveals rich markdown tooltips at **cluster**, **database**, and **collection** levels — giving you useful details at a glance without expanding or connecting. + +- **Cluster tooltips** show the display name, hostname, authentication method, and TLS status. +- **Database tooltips** display the database name and estimated storage size. +- **Collection tooltips** show estimated document count and storage size. + +All data comes from information already fetched during tree expansion, so no extra API calls are needed. + +#### 💠 **Copy Reference Context Menu** ([#545](https://github.com/microsoft/vscode-documentdb/pull/545), [#587](https://github.com/microsoft/vscode-documentdb/pull/587)) + +A new **"Copy Reference…"** right-click option is available on **database**, **collection**, and **index** nodes. Instead of copying a single fixed format, a QuickPick lets you choose the most useful representation: + +- **Database**: Name, Shell Command (`use dbName`), Qualified Name (`host/dbName`) +- **Collection**: Name, Namespace (`db.coll`), Shell Reference (`db.coll`), Shell Command (`db.getCollection("coll")`) +- **Index**: Name, Key Definition (`{ field: 1 }`), Shell Command (`db.coll.getIndexes().find(...)`) + +Names containing special characters (spaces, parentheses, etc.) automatically use the safe `db.getCollection("...")` form, and dot-notation options are hidden when they would produce broken syntax. + +Thanks to **[@bgaeddert](https://github.com/bgaeddert)** for the original contribution in [#545](https://github.com/microsoft/vscode-documentdb/pull/545)! + +#### 💠 **Improved CONTRIBUTING.md** ([#565](https://github.com/microsoft/vscode-documentdb/pull/565)) + +The contributing guide now includes a **PR submission checklist** (localization, formatting, linting), corrected Node/npm version requirements, and multi-platform setup stubs — making it easier for new contributors to get started. + +#### 💠 **Dependency Updates** ([#552](https://github.com/microsoft/vscode-documentdb/pull/552), [#556](https://github.com/microsoft/vscode-documentdb/pull/556), [#558](https://github.com/microsoft/vscode-documentdb/pull/558), [#586](https://github.com/microsoft/vscode-documentdb/pull/586)) + +Updated `handlebars` (4.7.8 → 4.7.9), `lodash` (4.17.23 → 4.18.1), `lodash` and `@microsoft/api-extractor` in `/api`, and `follow-redirects` (1.15.11 → 1.16.0) to their latest versions. + +### Changelog + +See the full changelog entry for this release: +➡️ [CHANGELOG.md#074](https://github.com/microsoft/vscode-documentdb/blob/main/CHANGELOG.md#074) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index a4493f78a..dedb2266a 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -118,12 +118,11 @@ "▶️ Run Command": "▶️ Run Command", "► Task '{taskName}' starting...": "► Task '{taskName}' starting...", "○ Task '{taskName}' initializing...": "○ Task '{taskName}' initializing...", - "⚠️ **Security:** TLS/SSL Disabled": "⚠️ **Security:** TLS/SSL Disabled", "⚠️ existing collection": "⚠️ existing collection", "⚠ TLS/SSL Disabled": "⚠ TLS/SSL Disabled", "⚠️ Warning: This will modify the existing collection. Documents with matching _id values will be handled based on your conflict resolution setting.": "⚠️ Warning: This will modify the existing collection. Documents with matching _id values will be handled based on your conflict resolution setting.", - "✅ **Security:** TLS/SSL Enabled": "✅ **Security:** TLS/SSL Enabled", "✓ Task '{taskName}' completed successfully. {message}": "✓ Task '{taskName}' completed successfully. {message}", + "💡 Your password appears to contain URL-encoded characters (e.g. %40 instead of @). This often happens when copying a password from a connection string URL. Would you like to retry with the decoded version?": "💡 Your password appears to contain URL-encoded characters (e.g. %40 instead of @). This often happens when copying a password from a connection string URL. Would you like to retry with the decoded version?", "$(add) Create...": "$(add) Create...", "$(arrow-left) Go Back": "$(arrow-left) Go Back", "$(check) Success": "$(check) Success", @@ -184,6 +183,7 @@ "Are you sure?": "Are you sure?", "Ask Copilot to generate the query for you": "Ask Copilot to generate the query for you", "Attempting to authenticate with \"{cluster}\"…": "Attempting to authenticate with \"{cluster}\"…", + "Auth": "Auth", "Authenticate to connect with your DocumentDB cluster": "Authenticate to connect with your DocumentDB cluster", "Authenticate to Connect with Your DocumentDB Cluster": "Authenticate to Connect with Your DocumentDB Cluster", "Authenticate using a username and password": "Authenticate using a username and password", @@ -267,7 +267,9 @@ "Configuring tenant filtering…": "Configuring tenant filtering…", "Conflict Resolution: {strategyName}": "Conflict Resolution: {strategyName}", "Connect to a database": "Connect to a database", + "Connected to \"{cluster}\" using the decoded password. Would you like to update your saved credentials?": "Connected to \"{cluster}\" using the decoded password. Would you like to update your saved credentials?", "Connected to \"{name}\"": "Connected to \"{name}\"", + "Connected to the cluster \"{cluster}\" using decoded password.": "Connected to the cluster \"{cluster}\" using decoded password.", "Connected to the cluster \"{cluster}\".": "Connected to the cluster \"{cluster}\".", "Connecting to \"{cluster}\"…": "Connecting to \"{cluster}\"…", "Connecting to the cluster as \"{username}\"…": "Connecting to the cluster as \"{username}\"…", @@ -282,12 +284,15 @@ "Connection: {connectionName}": "Connection: {connectionName}", "Connections have moved": "Connections have moved", "Continue": "Continue", + "Copied to clipboard": "Copied to clipboard", "Copy \"{sourceCollection}\" from \"{sourceDatabase}\" to \"{targetDatabase}/{targetCollection}\"": "Copy \"{sourceCollection}\" from \"{sourceDatabase}\" to \"{targetDatabase}/{targetCollection}\"", "Copy index definitions from source collection?": "Copy index definitions from source collection?", "Copy index definitions from source to target collection.": "Copy index definitions from source to target collection.", "Copy Indexes: {yesNoValue}": "Copy Indexes: {yesNoValue}", "Copy only documents without recreating indexes.": "Copy only documents without recreating indexes.", "Copy operation cancelled.": "Copy operation cancelled.", + "Copy Reference: {0}": "Copy Reference: {0}", + "Copy Reference: {0}.{1}": "Copy Reference: {0}.{1}", "Copy with password": "Copy with password", "Copy without password": "Copy without password", "Copy-and-Merge": "Copy-and-Merge", @@ -326,6 +331,7 @@ "Credentials updated successfully.": "Credentials updated successfully.", "Data shown was correct": "Data shown was correct", "Data shown was incorrect": "Data shown was incorrect", + "Database": "Database", "database \"{0}\"": "database \"{0}\"", "Database name cannot be longer than 64 characters.": "Database name cannot be longer than 64 characters.", "Database name cannot contain any of the following characters: \"{0}{1}\"": "Database name cannot contain any of the following characters: \"{0}{1}\"", @@ -512,6 +518,7 @@ "Failed to save credentials: {0}": "Failed to save credentials: {0}", "Failed to save credentials: connection not found in storage.": "Failed to save credentials: connection not found in storage.", "Failed to save credentials.": "Failed to save credentials.", + "Failed to save updated credentials: {error}": "Failed to save updated credentials: {error}", "Failed to sign in to tenant {0}: {1}": "Failed to sign in to tenant {0}: {1}", "Failed to start a session: {0}": "Failed to start a session: {0}", "Failed to start a transaction with the provided session: {0}": "Failed to start a transaction with the provided session: {0}", @@ -548,6 +555,7 @@ "Hide Index…": "Hide Index…", "Hiding index…": "Hiding index…", "HIGH PRIORITY": "HIGH PRIORITY", + "Host": "Host", "How do you want to connect?": "How do you want to connect?", "How should conflicts be handled during the copy operation?": "How should conflicts be handled during the copy operation?", "How would you rate Query Insights?": "How would you rate Query Insights?", @@ -639,6 +647,7 @@ "JSON View": "JSON View", "Keep-alive timeout exceeded": "Keep-alive timeout exceeded", "Keep-alive timeout exceeded: stream has been running for {0} seconds (limit: {1} seconds)": "Keep-alive timeout exceeded: stream has been running for {0} seconds (limit: {1} seconds)", + "Key Definition": "Key Definition", "Keys Examined": "Keys Examined", "Large Collection Copy Operation": "Large Collection Copy Operation", "Learn more": "Learn more", @@ -695,6 +704,8 @@ "Move to top level": "Move to top level", "Moved {0} item(s) to \"{1}\".": "Moved {0} item(s) to \"{1}\".", "N/A": "N/A", + "Name": "Name", + "Namespace": "Namespace", "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.", @@ -789,6 +800,7 @@ "Project": "Project", "Project: Specify which fields to include or exclude": "Project: Specify which fields to include or exclude", "Provider \"{0}\" does not have resource type \"{1}\".": "Provider \"{0}\" does not have resource type \"{1}\".", + "Qualified Name": "Qualified Name", "Query Efficiency Analysis": "Query Efficiency Analysis", "Query Execution Failed": "Query Execution Failed", "Query generation failed": "Query generation failed", @@ -833,6 +845,7 @@ "Resource group \"{0}\" already exists in subscription \"{1}\".": "Resource group \"{0}\" already exists in subscription \"{1}\".", "Results found": "Results found", "Retry": "Retry", + "Retry Error: {error}": "Retry Error: {error}", "Reusing active connection for \"{cluster}\".": "Reusing active connection for \"{cluster}\".", "Revisit connection details and try again.": "Revisit connection details and try again.", "Role assignment \"{0}\" created for the {2} resource \"{1}\".": "Role assignment \"{0}\" created for the {2} resource \"{1}\".", @@ -846,6 +859,7 @@ "Save to the database": "Save to the database", "Saving \"{path}\" will update the entity \"{name}\" to the cloud.": "Saving \"{path}\" will update the entity \"{name}\" to the cloud.", "Saving credentials for \"{clusterName}\"…": "Saving credentials for \"{clusterName}\"…", + "Security": "Security", "See output for more details.": "See output for more details.", "Select {0}": "Select {0}", "Select {mongoExecutableFileName}": "Select {mongoExecutableFileName}", @@ -874,6 +888,8 @@ "SHARD_MERGE · {0} shards": "SHARD_MERGE · {0} shards", "SHARD_MERGE · {0} shards · {1} docs · {2}ms": "SHARD_MERGE · {0} shards · {1} docs · {2}ms", "Shard: {0}": "Shard: {0}", + "Shell Command": "Shell Command", + "Shell Reference": "Shell Reference", "Show Output": "Show Output", "Show Stage Details": "Show Stage Details", "Sign in to additional accounts or authenticate with other tenants to see more options.": "Sign in to additional accounts or authenticate with other tenants to see more options.", @@ -1014,6 +1030,8 @@ "This will also delete {0}.": "This will also delete {0}.", "This will prevent the query planner from using this index.": "This will prevent the query planner from using this index.", "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting.": "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting.", + "TLS/SSL Disabled": "TLS/SSL Disabled", + "TLS/SSL Enabled": "TLS/SSL Enabled", "To connect to Azure resources, you need to sign in to Azure accounts.": "To connect to Azure resources, you need to sign in to Azure accounts.", "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.": "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.", "Too many arguments. Expecting 0 or 1 argument(s) to {constructorCall}": "Too many arguments. Expecting 0 or 1 argument(s) to {constructorCall}", @@ -1022,6 +1040,7 @@ "Transforming Stage 2 response to UI format": "Transforming Stage 2 response to UI format", "Tree View": "Tree View", "Try again": "Try again", + "Try with Decoded Password": "Try with Decoded Password", "Type \"it\" for more": "Type \"it\" for more", "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.", @@ -1059,11 +1078,13 @@ "Update Azure Account Extension to at least version \"{0}\"...": "Update Azure Account Extension to at least version \"{0}\"...", "Update cluster credentials": "Update cluster credentials", "Update Connection String": "Update Connection String", + "Update Saved Password": "Update Saved Password", "Updated entity \"{name}\".": "Updated entity \"{name}\".", "Upload": "Upload", "URL handling aborted. Connection was unsuccessful or the specified database/collection does not exist.": "URL handling aborted. Connection was unsuccessful or the specified database/collection does not exist.", "Use anyway": "Use anyway", "Use projection to return only necessary fields. This reduces network transfer and memory usage, especially important for documents with large embedded arrays or binary data.": "Use projection to return only necessary fields. This reduces network transfer and memory usage, especially important for documents with large embedded arrays or binary data.", + "User": "User", "Username and Password": "Username and Password", "Username cannot be empty": "Username cannot be empty", "Username contains characters that cannot be safely encoded.": "Username contains characters that cannot be safely encoded.", diff --git a/package-lock.json b/package-lock.json index 99efc83ff..e7ffe32c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-documentdb", - "version": "0.7.3", + "version": "0.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-documentdb", - "version": "0.7.3", + "version": "0.7.4", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@azure/arm-compute": "^22.4.0", @@ -12031,9 +12031,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -12592,9 +12592,9 @@ "license": "MIT" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -15350,9 +15350,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index ed394f432..edbb64e8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vscode-documentdb", - "version": "0.7.3", + "version": "0.7.4", "releaseNotesUrl": "https://github.com/microsoft/vscode-documentdb/discussions/489", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "publisher": "ms-azuretools", @@ -533,6 +533,12 @@ "category": "DocumentDB", "command": "vscode-documentdb.command.pasteCollection", "title": "Paste Collection…" + }, + { + "//": "Copy Reference", + "category": "DocumentDB", + "command": "vscode-documentdb.command.copyReference", + "title": "Copy Reference…" } ], "submenus": [ @@ -864,6 +870,24 @@ "command": "vscode-documentdb.command.pasteCollection", "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "3@4" + }, + { + "//": "[Database] Copy Reference", + "command": "vscode-documentdb.command.copyReference", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "yheAlmostLastGroup@1" + }, + { + "//": "[Collection] Copy Reference", + "command": "vscode-documentdb.command.copyReference", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "yheAlmostLastGroup@1" + }, + { + "//": "[Index] Copy Reference", + "command": "vscode-documentdb.command.copyReference", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "yheAlmostLastGroup@1" } ], "explorer/context": [], @@ -928,6 +952,10 @@ "command": "vscode-documentdb.command.azureResourcesView.addConnectionToConnectionsView", "when": "never" }, + { + "command": "vscode-documentdb.command.copyReference", + "when": "never" + }, { "command": "vscode-documentdb.command.copyConnectionString", "when": "never" diff --git a/src/commands/copyReference/copyReference.ts b/src/commands/copyReference/copyReference.ts new file mode 100644 index 000000000..39b7e74d6 --- /dev/null +++ b/src/commands/copyReference/copyReference.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { CollectionItem } from '../../tree/documentdb/CollectionItem'; +import { type DatabaseItem } from '../../tree/documentdb/DatabaseItem'; +import { IndexItem } from '../../tree/documentdb/IndexItem'; + +interface CopyReferenceOption { + id: string; + label: string; + detail: string; + alwaysShow: true; +} + +function formatIndexKey(key: Record): string { + const entries = Object.entries(key) + .map(([field, order]) => `${field}: ${order}`) + .join(', '); + return `{ ${entries} }`; +} + +/** + * Returns true if a name requires quoting (cannot be used in dot-notation). + * A name is safe for dot-notation only if it matches a valid JS identifier. + */ +function needsQuoting(name: string): boolean { + return !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name); +} + +function escapeDoubleQuotes(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function shellCollectionRef(collName: string): string { + return needsQuoting(collName) ? `db.getCollection("${escapeDoubleQuotes(collName)}")` : `db.${collName}`; +} + +function getClusterHost(connectionString: string | undefined): string | undefined { + if (!connectionString) { + return undefined; + } + + try { + const url = new URL(connectionString); + return url.host; + } catch { + return undefined; + } +} + +function opt(label: string, value: string): CopyReferenceOption { + return { id: value, label, detail: value, alwaysShow: true }; +} + +function getDatabaseOptions(node: DatabaseItem): CopyReferenceOption[] { + const dbName = node.databaseInfo.name; + const host = getClusterHost(node.cluster.connectionString); + + const options: CopyReferenceOption[] = [opt(l10n.t('Name'), dbName), opt(l10n.t('Shell Command'), `use ${dbName}`)]; + + if (host) { + options.push(opt(l10n.t('Qualified Name'), `${host}/${dbName}`)); + } + + return options; +} + +function getCollectionOptions(node: CollectionItem): CopyReferenceOption[] { + const dbName = node.databaseInfo.name; + const collName = node.collectionInfo.name; + const quoted = needsQuoting(collName) || needsQuoting(dbName); + const escapedCollName = escapeDoubleQuotes(collName); + + const options: CopyReferenceOption[] = [opt(l10n.t('Name'), collName)]; + + if (!quoted) { + options.push(opt(l10n.t('Namespace'), `${dbName}.${collName}`)); + options.push(opt(l10n.t('Shell Reference'), `db.${collName}`)); + } + + options.push(opt(l10n.t('Shell Command'), `db.getCollection("${escapedCollName}")`)); + + return options; +} + +function getIndexOptions(node: IndexItem): CopyReferenceOption[] { + const indexName = node.indexInfo.name; + const collName = node.collectionInfo.name; + + const options: CopyReferenceOption[] = [opt(l10n.t('Name'), indexName)]; + + if (node.indexInfo.key) { + const keyDef = formatIndexKey(node.indexInfo.key); + const collRef = shellCollectionRef(collName); + const escapedIndexName = escapeDoubleQuotes(indexName); + + options.push(opt(l10n.t('Key Definition'), keyDef)); + options.push( + opt(l10n.t('Shell Command'), `${collRef}.getIndexes().find(i => i.name === "${escapedIndexName}")`), + ); + } + + return options; +} + +function getOptionsForNode(node: DatabaseItem | CollectionItem | IndexItem): { + title: string; + options: CopyReferenceOption[]; +} { + if (node instanceof IndexItem) { + return { + title: l10n.t('Copy Reference: {0}', node.indexInfo.name), + options: getIndexOptions(node), + }; + } + + if (node instanceof CollectionItem) { + return { + title: l10n.t('Copy Reference: {0}.{1}', node.databaseInfo.name, node.collectionInfo.name), + options: getCollectionOptions(node), + }; + } + + return { + title: l10n.t('Copy Reference: {0}', node.databaseInfo.name), + options: getDatabaseOptions(node), + }; +} + +export async function copyReference( + context: IActionContext, + node: DatabaseItem | CollectionItem | IndexItem, +): Promise { + if (!node) { + throw new Error(l10n.t('No node selected.')); + } + + const { title, options } = getOptionsForNode(node); + + const picked = await context.ui.showQuickPick(options, { + placeHolder: title, + stepName: 'copyReference', + suppressPersistence: true, + }); + + await vscode.env.clipboard.writeText(picked.id); + void vscode.window.showInformationMessage(l10n.t('Copied to clipboard')); +} diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index 59f9422e8..6e7108f2d 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -29,6 +29,7 @@ import { renameConnection } from '../commands/connections-view/renameConnection/ import { renameFolder } from '../commands/connections-view/renameFolder/renameFolder'; import { copyCollection } from '../commands/copyCollection/copyCollection'; import { copyAzureConnectionString } from '../commands/copyConnectionString/copyConnectionString'; +import { copyReference } from '../commands/copyReference/copyReference'; import { createCollection } from '../commands/createCollection/createCollection'; import { createAzureDatabase } from '../commands/createDatabase/createDatabase'; import { createMongoDocument } from '../commands/createDocument/createDocument'; @@ -387,6 +388,11 @@ export class ClustersExtension implements vscode.Disposable { withTreeNodeCommandCorrelation(deleteAzureDatabase), ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.copyReference', + withTreeNodeCommandCorrelation(copyReference), + ); + registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.hideIndex', withTreeNodeCommandCorrelation(hideIndex), diff --git a/src/documentdb/auth/urlEncodedPassword.test.ts b/src/documentdb/auth/urlEncodedPassword.test.ts new file mode 100644 index 000000000..bb711e604 --- /dev/null +++ b/src/documentdb/auth/urlEncodedPassword.test.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { + showConnectionFailedAndMaybeOfferDecodedRetry, + tryDecodeUrlEncodedPassword, + UrlEncodedPasswordTelemetry, +} from './urlEncodedPassword'; + +function createMockContext(): IActionContext { + return { + telemetry: { properties: {}, measurements: {} }, + errorHandling: { issueProperties: {} }, + ui: {} as IActionContext['ui'], + valuesToMask: [], + } as unknown as IActionContext; +} + +describe('tryDecodeUrlEncodedPassword', () => { + it('returns undefined for undefined input', () => { + expect(tryDecodeUrlEncodedPassword(undefined)).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + expect(tryDecodeUrlEncodedPassword('')).toBeUndefined(); + }); + + it('returns undefined when password has no percent-encoded sequences', () => { + expect(tryDecodeUrlEncodedPassword('plainPassword123')).toBeUndefined(); + }); + + it('returns undefined when password contains % but not valid encoding', () => { + // e.g. "100%" — the % is not followed by two hex digits + expect(tryDecodeUrlEncodedPassword('100%')).toBeUndefined(); + }); + + it('returns undefined when password contains %XX that is not valid UTF-8', () => { + // %C3 alone is an incomplete UTF-8 sequence; decodeURIComponent should throw + expect(tryDecodeUrlEncodedPassword('%C3')).toBeUndefined(); + }); + + it('returns decoded password for %40 (@)', () => { + expect(tryDecodeUrlEncodedPassword('p%40ss')).toBe('p@ss'); + }); + + it('returns decoded password for multiple encoded characters', () => { + expect(tryDecodeUrlEncodedPassword('p%40ss%21w%23rd')).toBe('p@ss!w#rd'); + }); + + it('returns decoded password for %20 (space)', () => { + expect(tryDecodeUrlEncodedPassword('my%20password')).toBe('my password'); + }); + + it('returns undefined when decoding produces the same string', () => { + // %30 decodes to "0", so "abc%30" decodes to "abc0" + // But a password like "%41" decodes to "A", which differs. + // A password that is already decoded with no encoded chars won't match the pattern. + // Let's use a case where decoded === original: not possible with valid %XX that differs. + // Actually if someone has "%25" it decodes to "%", so that's always different. + // This edge case is hard to trigger with valid encoding, so skip pure equality test. + }); + + it('handles case-insensitive hex digits', () => { + expect(tryDecodeUrlEncodedPassword('p%2Fss')).toBe('p/ss'); + expect(tryDecodeUrlEncodedPassword('p%2fss')).toBe('p/ss'); + }); +}); + +describe('showConnectionFailedAndMaybeOfferDecodedRetry', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const showErrorMessage: jest.Mock = vscode.window.showErrorMessage as unknown as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows error dialog without retry button when password is not URL-encoded', async () => { + const context = createMockContext(); + + showErrorMessage.mockResolvedValueOnce(undefined); + + const result = await showConnectionFailedAndMaybeOfferDecodedRetry({ + clusterName: 'test-cluster', + password: 'plainPassword', + isNativeAuth: true, + originalError: new Error('auth failed'), + context, + }); + + expect(result.decodedPassword).toBeUndefined(); + expect(context.telemetry.properties[UrlEncodedPasswordTelemetry.Detected]).toBe('false'); + expect(context.telemetry.properties[UrlEncodedPasswordTelemetry.Offered]).toBe('false'); + // The dialog should have been called with no extra buttons + expect(showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('test-cluster'), + expect.objectContaining({ modal: true }), + ); + }); + + it('shows error dialog without retry button when auth is not native', async () => { + const context = createMockContext(); + + showErrorMessage.mockResolvedValueOnce(undefined); + + const result = await showConnectionFailedAndMaybeOfferDecodedRetry({ + clusterName: 'test-cluster', + password: 'p%40ss', // URL-encoded but non-native auth + isNativeAuth: false, + originalError: new Error('auth failed'), + context, + }); + + expect(result.decodedPassword).toBeUndefined(); + expect(context.telemetry.properties[UrlEncodedPasswordTelemetry.Detected]).toBe('false'); + }); + + it('offers retry button when password is URL-encoded and auth is native', async () => { + const context = createMockContext(); + + showErrorMessage.mockResolvedValueOnce(undefined); + + const result = await showConnectionFailedAndMaybeOfferDecodedRetry({ + clusterName: 'test-cluster', + password: 'p%40ss', + isNativeAuth: true, + originalError: new Error('auth failed'), + context, + }); + + expect(result.decodedPassword).toBeUndefined(); // user didn't click retry + expect(context.telemetry.properties[UrlEncodedPasswordTelemetry.Detected]).toBe('true'); + expect(context.telemetry.properties[UrlEncodedPasswordTelemetry.Offered]).toBe('true'); + expect(context.telemetry.properties[UrlEncodedPasswordTelemetry.Accepted]).toBe('false'); + // Should have been called with the retry button + expect(showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('test-cluster'), + expect.objectContaining({ modal: true }), + expect.stringContaining('Decoded Password'), + ); + }); + + it('returns decoded password when user clicks retry button', async () => { + const context = createMockContext(); + + // Simulate user clicking the retry button + showErrorMessage.mockImplementation((_msg: string, _opts: unknown, ...buttons: string[]) => + Promise.resolve(buttons[0]), + ); + + const result = await showConnectionFailedAndMaybeOfferDecodedRetry({ + clusterName: 'test-cluster', + password: 'p%40ss', + isNativeAuth: true, + originalError: new Error('auth failed'), + context, + }); + + expect(result.decodedPassword).toBe('p@ss'); + expect(context.telemetry.properties[UrlEncodedPasswordTelemetry.Accepted]).toBe('true'); + }); + + it('handles non-Error original error', async () => { + const context = createMockContext(); + + showErrorMessage.mockResolvedValueOnce(undefined); + + await showConnectionFailedAndMaybeOfferDecodedRetry({ + clusterName: 'test-cluster', + password: undefined, + isNativeAuth: true, + originalError: 'string error', + context, + }); + + // Should not throw; the dialog detail should contain the stringified error + expect(showErrorMessage).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + detail: expect.stringContaining('string error'), + }), + ); + }); + + it('shows error dialog without retry button when password is undefined', async () => { + const context = createMockContext(); + + showErrorMessage.mockResolvedValueOnce(undefined); + + const result = await showConnectionFailedAndMaybeOfferDecodedRetry({ + clusterName: 'test-cluster', + password: undefined, + isNativeAuth: true, + originalError: new Error('auth failed'), + context, + }); + + expect(result.decodedPassword).toBeUndefined(); + expect(context.telemetry.properties[UrlEncodedPasswordTelemetry.Detected]).toBe('false'); + }); +}); diff --git a/src/documentdb/auth/urlEncodedPassword.ts b/src/documentdb/auth/urlEncodedPassword.ts new file mode 100644 index 000000000..c842e49fe --- /dev/null +++ b/src/documentdb/auth/urlEncodedPassword.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; + +/** + * Helpers for detecting and recovering from URL-encoded passwords. + * + * Users sometimes paste a password copied from a connection-string URL and + * forget to URL-decode it. The server then rejects the credentials because + * the actual password characters differ from the encoded form (e.g. "p%40ss" + * vs. "p@ss"). Rather than silently retrying — which could trip brute-force + * lockouts on the server side — we show a one-time prompt and let the user + * decide whether to retry with the decoded value. + */ + +/** Matches a `%XX` percent-encoded byte. */ +const URL_ENCODED_BYTE_PATTERN = /%[0-9A-Fa-f]{2}/; + +/** + * Returns the decoded password if `password` contains `%XX` sequences and + * `decodeURIComponent` produces a different, non-empty string; otherwise + * returns `undefined`. Intended as a hint — callers must never auto-apply + * the decoded value without user consent. + */ +export function tryDecodeUrlEncodedPassword(password: string | undefined): string | undefined { + if (!password || !URL_ENCODED_BYTE_PATTERN.test(password)) { + return undefined; + } + + let decoded: string; + try { + decoded = decodeURIComponent(password); + } catch { + return undefined; + } + + if (decoded.length === 0 || decoded === password) { + return undefined; + } + + return decoded; +} + +/** + * Telemetry property keys reported by {@link showConnectionFailedAndMaybeOfferDecodedRetry}. + * No password material is ever recorded — only boolean indicators about the flow. + */ +export const UrlEncodedPasswordTelemetry = { + /** `true` when the original password looked URL-encoded and a decoded variant was available. */ + Detected: 'urlDecodePasswordDetected', + /** `true` when the user was shown the "Retry with decoded password" button. */ + Offered: 'urlDecodePasswordOffered', + /** `true` when the user clicked the retry button. */ + Accepted: 'urlDecodePasswordAccepted', +} as const; + +export interface ShowConnectionFailedOptions { + /** Display name of the cluster shown in the error dialog. */ + readonly clusterName: string; + /** The password that was used for the failed connection attempt (may be undefined for non-password auth). */ + readonly password: string | undefined; + /** Whether the failed attempt used password-based (native) auth. Retry is only offered for native auth. */ + readonly isNativeAuth: boolean; + /** The error returned by the failed connection attempt. */ + readonly originalError: unknown; + /** Action context used to record non-sensitive telemetry about the retry flow. */ + readonly context: IActionContext; +} + +export interface ShowConnectionFailedResult { + /** + * Populated with the decoded password only when the user explicitly chose to retry. + * Callers must mask this value via `context.valuesToMask` before use. + */ + readonly decodedPassword?: string; +} + +/** + * Shows the "Failed to connect" modal. If the password looks URL-encoded, adds a + * one-time "Retry with decoded password" button. Records telemetry about whether + * the hint was offered and accepted. + */ +export async function showConnectionFailedAndMaybeOfferDecodedRetry( + options: ShowConnectionFailedOptions, +): Promise { + const { clusterName, password, isNativeAuth, originalError, context } = options; + + const decodedPassword = isNativeAuth ? tryDecodeUrlEncodedPassword(password) : undefined; + const canOfferRetry = decodedPassword !== undefined; + + context.telemetry.properties[UrlEncodedPasswordTelemetry.Detected] = canOfferRetry ? 'true' : 'false'; + context.telemetry.properties[UrlEncodedPasswordTelemetry.Offered] = canOfferRetry ? 'true' : 'false'; + + const errorMessage = originalError instanceof Error ? originalError.message : String(originalError); + + let detail = + l10n.t('Revisit connection details and try again.') + + '\n\n' + + l10n.t('Error: {error}', { error: errorMessage }); + + const retryButton = l10n.t('Try with Decoded Password'); + const buttons: string[] = []; + + if (canOfferRetry) { + detail += + '\n\n' + + l10n.t( + '💡 Your password appears to contain URL-encoded characters (e.g. %40 instead of @). This often happens when copying a password from a connection string URL. Would you like to retry with the decoded version?', + ); + buttons.push(retryButton); + } + + const selected = await vscode.window.showErrorMessage( + l10n.t('Failed to connect to "{cluster}"', { cluster: clusterName }), + { modal: true, detail }, + ...buttons, + ); + + const accepted = canOfferRetry && selected === retryButton; + context.telemetry.properties[UrlEncodedPasswordTelemetry.Accepted] = accepted ? 'true' : 'false'; + + return { decodedPassword: accepted ? decodedPassword : undefined }; +} diff --git a/src/tree/connections-view/ConnectionsBranchDataProvider.ts b/src/tree/connections-view/ConnectionsBranchDataProvider.ts index 052054c35..359122a9e 100644 --- a/src/tree/connections-view/ConnectionsBranchDataProvider.ts +++ b/src/tree/connections-view/ConnectionsBranchDataProvider.ts @@ -150,6 +150,8 @@ export class ConnectionsBranchDataProvider extends BaseExtendedTreeDataProvider< dbExperience: DocumentDBExperience, connectionString: connection.secrets.connectionString, emulatorConfiguration: connection.properties.emulatorConfiguration, + selectedAuthMethod: connection.properties.selectedAuthMethod, + connectionUser: connection.secrets.nativeAuthConfig?.connectionUser, }; ext.outputChannel.trace( diff --git a/src/tree/connections-view/DocumentDBClusterItem.ts b/src/tree/connections-view/DocumentDBClusterItem.ts index 34bcbbf69..53383a6db 100644 --- a/src/tree/connections-view/DocumentDBClusterItem.ts +++ b/src/tree/connections-view/DocumentDBClusterItem.ts @@ -13,7 +13,14 @@ 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 { + authMethodFromString, + AuthMethodId, + authMethodsFromString, + getAuthMethod, + isSupportedAuthMethod, +} from '../../documentdb/auth/AuthMethod'; +import { showConnectionFailedAndMaybeOfferDecodedRetry } from '../../documentdb/auth/urlEncodedPassword'; import { ClustersClient } from '../../documentdb/ClustersClient'; import { CredentialCache } from '../../documentdb/CredentialCache'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; @@ -30,6 +37,14 @@ import { type TreeCluster } from '../models/BaseClusterModel'; import { type TreeElementWithStorageId } from '../TreeElementWithStorageId'; import { type ConnectionClusterModel } from './models/ConnectionClusterModel'; +/** + * Escapes markdown special characters so user-provided text is always rendered + * as plain text rather than being interpreted as markdown formatting or links. + */ +function escapeMarkdown(text: string): string { + return text.replace(/[\\`*_{}[\]()#+\-.!|~]/g, '\\$&'); +} + export class DocumentDBClusterItem extends ClusterItemBase implements TreeElementWithStorageId { public override readonly cluster: TreeCluster; @@ -235,16 +250,93 @@ export class DocumentDBClusterItem extends ClusterItemBase 0) { + const escapedHosts = hosts.map((host) => escapeMarkdown(host)); + md.appendMarkdown(`**${l10n.t('Host')}:** ${escapedHosts.join(', ')}\n\n`); + } + + // Auth method + const authMethodId = this.cluster.selectedAuthMethod; + if (authMethodId) { + const isSupported = isSupportedAuthMethod(authMethodId); + const authLabel = isSupported ? getAuthMethod(authMethodId).label : authMethodId; + md.appendMarkdown(`**${l10n.t('Auth')}:** ${escapeMarkdown(authLabel)}\n\n`); + + if (isSupported && authMethodId === AuthMethodId.NativeAuth && this.cluster.connectionUser) { + md.appendMarkdown(`**${l10n.t('User')}:** ${escapeMarkdown(this.cluster.connectionUser)}\n\n`); + } + } + + // Emulator security notice + if (this.cluster.emulatorConfiguration?.isEmulator) { + if (this.cluster.emulatorConfiguration.disableEmulatorSecurity) { + md.appendMarkdown(`⚠️ **${l10n.t('Security')}:** ${l10n.t('TLS/SSL Disabled')}\n\n`); + } else { + md.appendMarkdown(`✅ **${l10n.t('Security')}:** ${l10n.t('TLS/SSL Enabled')}\n\n`); + } + } + + return md; + } + + /** + * Extracts the host(s) from the connection string for display in the tooltip. + * Returns an empty array if the connection string is unavailable or unparseable. + */ + private getHosts(): string[] { + if (!this.cluster.connectionString) { + return []; + } + try { + return new DocumentDBConnectionString(this.cluster.connectionString).hosts ?? []; + } catch { + return []; + } + } } diff --git a/src/tree/connections-view/FolderItem.ts b/src/tree/connections-view/FolderItem.ts index 398f3a6bb..795fc1022 100644 --- a/src/tree/connections-view/FolderItem.ts +++ b/src/tree/connections-view/FolderItem.ts @@ -107,6 +107,8 @@ export class FolderItem implements TreeElement, TreeElementWithContextValue { dbExperience: DocumentDBExperience, connectionString: child?.secrets?.connectionString ?? undefined, emulatorConfiguration: child.properties.emulatorConfiguration, + selectedAuthMethod: child.properties.selectedAuthMethod, + connectionUser: child.secrets?.nativeAuthConfig?.connectionUser, }; ext.outputChannel.trace( diff --git a/src/tree/connections-view/LocalEmulators/LocalEmulatorsItem.ts b/src/tree/connections-view/LocalEmulators/LocalEmulatorsItem.ts index d4fe00261..20fb14d4b 100644 --- a/src/tree/connections-view/LocalEmulators/LocalEmulatorsItem.ts +++ b/src/tree/connections-view/LocalEmulators/LocalEmulatorsItem.ts @@ -69,6 +69,8 @@ export class LocalEmulatorsItem implements TreeElement, TreeElementWithContextVa dbExperience: DocumentDBExperience, connectionString: connection.secrets.connectionString, emulatorConfiguration: emulatorConfiguration, + selectedAuthMethod: connection.properties.selectedAuthMethod, + connectionUser: connection.secrets.nativeAuthConfig?.connectionUser, }; ext.outputChannel.trace( diff --git a/src/tree/connections-view/models/ConnectionClusterModel.ts b/src/tree/connections-view/models/ConnectionClusterModel.ts index 127f7334e..2b4fc24ea 100644 --- a/src/tree/connections-view/models/ConnectionClusterModel.ts +++ b/src/tree/connections-view/models/ConnectionClusterModel.ts @@ -27,4 +27,17 @@ export interface ConnectionClusterModel extends BaseClusterModel { * Present when this connection represents a local emulator instance. */ emulatorConfiguration?: EmulatorConfiguration; + + /** + * The selected authentication method ID (e.g. 'NativeAuth', 'MicrosoftEntraID'). + * Populated from storage when the tree item is built, used for tooltip display. + */ + selectedAuthMethod?: string; + + /** + * The connection username for native (SCRAM) authentication. + * Populated from storage when the tree item is built, used for tooltip display. + * Never contains a password. + */ + connectionUser?: string; } diff --git a/src/tree/documentdb/CollectionItem.ts b/src/tree/documentdb/CollectionItem.ts index 48dc6d35a..c5e79b427 100644 --- a/src/tree/documentdb/CollectionItem.ts +++ b/src/tree/documentdb/CollectionItem.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { createContextValue } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; import { ClustersClient, type CollectionItemModel, type DatabaseItemModel } from '../../documentdb/ClustersClient'; import { type Experience } from '../../DocumentDBExperiences'; @@ -16,6 +17,14 @@ import { type TreeElementWithExperience } from '../TreeElementWithExperience'; import { DocumentsItem } from './DocumentsItem'; import { IndexesItem } from './IndexesItem'; +/** + * Escapes markdown special characters so user-provided text is always rendered + * as plain text rather than being interpreted as markdown formatting or links. + */ +function escapeMarkdown(text: string): string { + return text.replace(/[\\`*_{}[\]()#+\-.!|~]/g, '\\$&'); +} + export class CollectionItem implements TreeElement, TreeElementWithExperience, TreeElementWithContextValue { public readonly id: string; public readonly experience: Experience; @@ -98,8 +107,36 @@ export class CollectionItem implements TreeElement, TreeElementWithExperience, T contextValue: this.contextValue, label: this.collectionInfo.name, description, + tooltip: this.buildTooltip(), iconPath: new vscode.ThemeIcon('folder-library'), collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, }; } + + /** + * Builds a markdown tooltip showing the collection name, type, and document count. + */ + private buildTooltip(): vscode.MarkdownString { + const md = new vscode.MarkdownString(); + md.isTrusted = false; + + md.appendMarkdown(`### ${escapeMarkdown(this.collectionInfo.name)}\n\n`); + + // Type badge (Collection, View, Timeseries) + const collectionType = this.collectionInfo.type ?? 'collection'; + const capitalizedType = collectionType.charAt(0).toUpperCase() + collectionType.slice(1); + md.appendMarkdown(`\`${capitalizedType}\`\n\n`); + + md.appendMarkdown('---\n\n'); + + // Database context + md.appendMarkdown(`**${l10n.t('Database')}:** ${escapeMarkdown(this.databaseInfo.name)}\n\n`); + + // Document count + if (typeof this.documentCount === 'number') { + md.appendMarkdown(`**${l10n.t('Documents')}:** ${formatDocumentCount(this.documentCount)}\n\n`); + } + + return md; + } } diff --git a/src/tree/documentdb/DatabaseItem.ts b/src/tree/documentdb/DatabaseItem.ts index e3b14580e..c6a8eef47 100644 --- a/src/tree/documentdb/DatabaseItem.ts +++ b/src/tree/documentdb/DatabaseItem.ts @@ -14,6 +14,14 @@ import { type TreeElementWithContextValue } from '../TreeElementWithContextValue import { type TreeElementWithExperience } from '../TreeElementWithExperience'; import { CollectionItem } from './CollectionItem'; +/** + * Escapes markdown special characters so user-provided text is always rendered + * as plain text rather than being interpreted as markdown formatting or links. + */ +function escapeMarkdown(text: string): string { + return text.replace(/[\\`*_{}[\]()#+\-.!|~]/g, '\\$&'); +} + export class DatabaseItem implements TreeElement, TreeElementWithExperience, TreeElementWithContextValue { public readonly id: string; public readonly experience: Experience; @@ -66,8 +74,23 @@ export class DatabaseItem implements TreeElement, TreeElementWithExperience, Tre id: this.id, contextValue: this.contextValue, label: this.databaseInfo.name, + tooltip: this.buildTooltip(), iconPath: new vscode.ThemeIcon('database'), // TODO: create our own icon here, this one's shape can change collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, }; } + + /** + * Builds a markdown tooltip showing the database name. + */ + private buildTooltip(): vscode.MarkdownString { + const md = new vscode.MarkdownString(); + md.isTrusted = false; + + md.appendMarkdown(`### ${escapeMarkdown(this.databaseInfo.name)}\n\n`); + + md.appendMarkdown(`\`${l10n.t('Database')}\`\n\n`); + + return md; + } }