diff --git a/.vscode/settings.json b/.vscode/settings.json index 6f588aa7ffa..d2111f1b63d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,8 @@ { "cSpell.import": ["cspell.yaml"], - "go.testFlags": ["-timeout", "30m"] + "go.testFlags": [ + "-timeout", + "30m" + ], + "aspire.enableSettingsFileCreationPromptOnStartup": false } diff --git a/ext/vscode/.github/copilot-instructions.md b/ext/vscode/.github/copilot-instructions.md new file mode 100644 index 00000000000..b757af00c0a --- /dev/null +++ b/ext/vscode/.github/copilot-instructions.md @@ -0,0 +1,186 @@ +# Azure Developer CLI VS Code Extension - Copilot Instructions + +## Project Overview +This is the official Visual Studio Code extension for the Azure Developer CLI (azd). It provides an integrated development experience for building, deploying, and managing Azure applications. + +## Core Development Principles + +### Documentation +- **Always keep the [README.md](../README.md) up to date** with any changes to: + - Features and functionality + - Commands and usage + - Configuration options + - Installation instructions + - Prerequisites + - Known issues or limitations + +### Code Quality & Testing +Before submitting any changes or pushing code, **always run the following checks** to avoid pipeline failures: + +1. **Linting**: `npm run lint` + - Ensures code follows TypeScript and ESLint standards + - Fix any linting errors before committing + +2. **Spell Check**: `npx cspell "src/**/*.ts"` + - Checks for spelling errors in source code + - Add technical terms to `.cspell.json` if needed + +3. **Unit Tests**: `npm run unit-test` + - Runs fast unit tests without full VS Code integration + - All tests must pass before committing + +4. **CI Test Suite**: `pwsh ./ci-test.ps1` (or `./ci-test.ps1` on Windows) + - Runs the full CI test pipeline locally + - This is the same test suite that runs in CI/CD + - **Must pass before pushing to avoid pipeline failures** + +### Pre-Commit Checklist +✅ Run `npm run lint` and fix all issues +✅ Run `npx cspell "src/**/*.ts"` and fix spelling errors +✅ Run `npm run unit-test` and ensure all tests pass +✅ Run `pwsh ./ci-test.ps1` and verify CI tests pass +✅ Update [README.md](../README.md) if functionality changed +✅ Verify merge conflicts are resolved (no `<<<<<<<`, `=======`, `>>>>>>>` markers) + +## Code Style & Conventions + +### File Organization +- Extension entry point: `src/extension.ts` +- Commands: `src/commands/` +- Language features: `src/language/` (IntelliSense, diagnostics, etc.) +- Views & tree providers: `src/views/` +- Utilities: `src/utils/` +- Tests: `src/test/` + +### Naming Conventions +- Use PascalCase for classes and interfaces +- Use camelCase for functions, methods, and variables +- Use descriptive names that clearly indicate purpose +- Prefix private members with underscore if needed for clarity + +### TypeScript Guidelines +- Use explicit types where possible, avoid `any` +- Leverage VS Code API types from `vscode` module +- Use `async/await` for asynchronous operations +- Handle errors gracefully with try/catch blocks + +### Azure YAML Language Features +When working on `azure.yaml` language support in `src/language/`: +- Use YAML parser from `yaml` package +- Provide helpful diagnostics with clear error messages +- Use `vscode.l10n.t()` for all user-facing strings +- Test with various `azure.yaml` configurations + +### Testing +- Write unit tests for new features in `src/test/suite/unit/` +- Use Mocha for test framework +- Use Chai for assertions +- Mock VS Code APIs when necessary using Sinon +- Keep tests focused and isolated + +## Common Tasks + +### Adding a New Command +1. Create command handler in `src/commands/` +2. Register in `src/commands/registerCommands.ts` +3. Add to `package.json` contributions +4. Add localized strings to `package.nls.json` +5. Update README.md with new command documentation +6. Add tests for the command + +### Adding Language Features +1. Create provider in `src/language/` +2. Register in `src/language/languageFeatures.ts` +3. Test with various `azure.yaml` files +4. Add diagnostics tests in `src/test/suite/unit/` + +### Debugging the Extension +- Press F5 to launch Extension Development Host +- Set breakpoints in TypeScript source +- Use Debug Console for logging +- Check Output > Azure Developer CLI for extension logs + +## VS Code Extension APIs +- Follow [VS Code Extension API](https://code.visualstudio.com/api) best practices +- Use `@microsoft/vscode-azext-utils` for Azure extension utilities +- Integrate with Azure Resources API via `@microsoft/vscode-azureresources-api` +- Use localization with `vscode.l10n.t()` for all user-facing text + +## Performance Best Practices + +### Activation & Startup +- **Minimize activation time**: Keep `activate()` function lightweight +- Use **lazy activation events** - be specific with `activationEvents` in package.json +- Avoid synchronous file I/O during activation +- Defer expensive operations until they're actually needed +- Use `ExtensionContext.subscriptions` for proper cleanup + +### Memory Management +- **Dispose resources properly**: Always dispose of subscriptions, watchers, and providers +- Use `vscode.Disposable` pattern for all resources that need cleanup +- Avoid memory leaks by unsubscribing from events when no longer needed +- Clear caches and collections when they grow too large +- Use weak references where appropriate + +### Asynchronous Operations +- **Never block the main thread**: Use async/await for all I/O operations +- Use `Promise.all()` for parallel operations when possible +- Implement proper cancellation using `CancellationToken` +- Debounce frequent operations (e.g., text document changes) +- Use background workers for CPU-intensive tasks + +### Tree Views & Data Providers +- Implement efficient `getChildren()` - return only visible items +- Cache tree data when appropriate to avoid redundant queries +- Use `vscode.EventEmitter` efficiently - only fire events when data actually changes +- Implement `getTreeItem()` to be synchronous and fast +- Use `collapsibleState` wisely to control initial expansion + +### Language Features +- **Debounce document change events** (see `documentDebounce.ts`) +- Use incremental parsing when possible +- Cache parsed ASTs or syntax trees +- Limit diagnostic computation to visible range when feasible +- Return early from providers when results aren't needed + +### File System Operations +- Use `vscode.workspace.fs` API for better performance +- Batch file operations when possible +- Use `FileSystemWatcher` instead of polling +- Avoid recursive directory scans in large workspaces +- Cache file system queries with appropriate invalidation + +### Commands & UI +- Keep command handlers fast and responsive +- Show progress indicators for long-running operations +- Use `withProgress()` for operations that take >1 second +- Provide cancellation support for long operations +- Avoid multiple sequential `showQuickPick` or `showInputBox` calls + +### Extension Size & Bundle +- Minimize extension bundle size - exclude unnecessary dependencies +- Use webpack to bundle and tree-shake code +- Lazy load large dependencies only when needed +- Consider code splitting for rarely-used features +- Optimize images and assets + +### Best Practices from This Codebase +- Use `documentDebounce()` utility for text change events (1000ms delay) +- Leverage `Lazy` and `AsyncLazy` for deferred initialization +- Implement proper `vscode.Disposable` cleanup in all providers +- Use telemetry to measure and track performance metrics +- Follow the patterns in `src/views/` for efficient tree providers + +## Build & Package +- Development build: `npm run dev-build` +- Production build: `npm run build` +- Watch mode: `npm run watch` +- Package extension: `npm run package` +- CI build: `npm run ci-build` +- CI package: `npm run ci-package` + +## Additional Resources +- [Azure Developer CLI Documentation](https://learn.microsoft.com/azure/developer/azure-developer-cli/) +- [VS Code Extension API](https://code.visualstudio.com/api) +- [Contributing Guide](../CONTRIBUTING.md) +- [Test Coverage](../TEST_COVERAGE.md) diff --git a/ext/vscode/.vscode/cspell-dictionary.txt b/ext/vscode/.vscode/cspell-dictionary.txt index 88075f21529..61934dd0a4b 100644 --- a/ext/vscode/.vscode/cspell-dictionary.txt +++ b/ext/vscode/.vscode/cspell-dictionary.txt @@ -13,3 +13,26 @@ containerapp staticwebapp devcenter processutils +azurecontainerapps +azurefunctions +azurestorage +eastus +mystorageaccount +mycosmosdb +azext +prerestore +postrestore +preprovision +postprovision +predeploy +postdeploy +invalidhost +unknownkeyword +aicollection +webapps +reactjs +azuresql +azuredb +retval +LOCALAPPDATA +ellismg diff --git a/ext/vscode/FEATURE_IDEAS.md b/ext/vscode/FEATURE_IDEAS.md new file mode 100644 index 00000000000..f5011cd90f5 --- /dev/null +++ b/ext/vscode/FEATURE_IDEAS.md @@ -0,0 +1,186 @@ +# Azure Developer CLI VS Code Extension - Feature Ideas + +This document outlines potential features and enhancements for the Azure Developer CLI VS Code extension. + +## Developer Experience Enhancements + +### 1. Interactive Dashboard/Overview + +- Real-time status view showing deployment health, resource costs, and environment states +- Quick action tiles for common workflows (provision → deploy → monitor) +- Recent activity log with links to outputs + +### 2. Integrated Debugging Support + +- Direct attach to Azure Container Apps or App Service instances +- Port forwarding shortcuts from the tree view +- Log streaming with syntax highlighting and filtering + +### 3. Cost Management Integration + +- Display estimated/actual costs per environment in the tree view +- Cost breakdown by resource +- Alerts when costs exceed thresholds + +## Workflow & Productivity + +### 4. Smart Templates & Scaffolding ~~(COMPLETED)~~ + +- ~~Template preview before init (show what services will be created)~~ +- ~~Custom template wizard with live preview~~ +- ~~"Add service" command to existing azure.yaml (add database, cache, etc.)~~ +- ~~Browse templates from awesome-azd gallery (https://aka.ms/awesome-azd)~~ +- ~~Filter templates by AI/ML focus (https://aka.ms/aiapps)~~ +- ~~Search templates by language, framework, or Azure service~~ +- ~~Quick start options for users without azure.yaml (init from code, minimal project)~~ +- ~~Category-based template browsing (AI, Web Apps, APIs, Containers, Databases)~~ + +### 5. Environment Diff & Management + +- Compare configurations between environments +- Bulk environment variable editor with autocomplete +- Environment templates/presets (dev, staging, prod) + +### 6. Pipeline & CI/CD Enhancements + +- Visualize pipeline runs directly in VS Code +- Monitor GitHub Actions/Azure DevOps pipelines +- One-click rollback to previous deployment + +## Language & IntelliSense + +### 7. Enhanced azure.yaml Support ~~(COMPLETED)~~ + +- ~~Auto-completion for service names, hooks, and configurations~~ +- ~~Inline documentation on hover~~ +- ~~Validation warnings for common mistakes~~ +- ~~Quick fixes for errors (missing dependencies, invalid references)~~ + +### 8. Multi-file Refactoring + +- Rename project across all files (azure.yaml, bicep, configs) +- Find all references to services/resources +- Safe rename operations + +## Observability & Monitoring + +### 9. Advanced Monitoring + +- Embed Application Insights charts in VS Code +- Custom metric dashboards +- Alert configuration UI +- Log analytics query builder + +### 10. Health Checks & Diagnostics + +- Pre-deployment validation (check quotas, permissions, naming conflicts) +- Post-deployment smoke tests +- Resource dependency graph visualization + +## Collaboration & Documentation + +### 11. Team Collaboration + +- Share environment configurations safely (secrets excluded) +- Project documentation generator from azure.yaml +- Architecture diagram generation from resources + +### 12. Testing Support + +- Integration test runner for deployed services +- API testing (similar to REST Client) +- Load testing integration + +## Infrastructure & Resources + +### 13. Resource Browser + +- Navigate Azure resources with rich details +- Quick actions (restart, scale, view logs, etc.) +- Resource connection strings with secure copy + +### 14. Infrastructure as Code Tools + +- Bicep/Terraform preview and validation +- What-if analysis before provision +- Generate bicep from existing resources + +### 15. Local Development + +- Enhanced emulator support (Cosmos DB, Storage, etc.) +- Service dependency orchestration (docker-compose integration) +- Local-to-cloud context switching + +## Quick Wins + +### 16. UX Improvements + +- Search across all commands and views +- Keyboard shortcuts for common operations +- Status bar indicators for active environment +- Toast notifications for long-running operations + +### 17. Settings & Preferences + +- Favorite/pinned environments +- Custom command aliases +- Auto-refresh intervals for views +- Default region/subscription preferences + +## Walkthrough & Onboarding + +### 18. Enhanced Getting Started Experience + +- **Post-Deployment Steps**: Add guidance on what to do after `azd up` completes (verify deployment, view resources, access endpoints) +- **Troubleshooting Guidance**: Inline tips for common errors (auth failures, quota issues, etc.) +- **Interactive Media**: Replace static SVGs with animated GIFs or embedded videos showing actual workflows +- **Progress Feedback**: Add completion events for all steps and show estimated time for each step + +### 19. Improved Walkthrough Content + +- **Contextual Steps**: Detect project type and customize walkthrough for specific languages/frameworks +- **Progressive Disclosure**: Show advanced options after basic walkthrough with "Learn more" expansions +- **Better Completion Detection**: Track actual deployment success/failure, not just command execution +- **Explanation of Concepts**: Clarify what happens during `provision` vs `deploy` vs `up` + +### 20. Additional Walkthrough Steps + +- **Environment Configuration**: Guide for setting environment variables, .env file usage, and secrets management +- **Local Development**: Add steps for `azd restore` and testing services locally before deploying +- **Monitoring & Iteration**: Dedicated step for `azd monitor` and viewing logs/troubleshooting +- **Cleanup Guidance**: Optional step for `azd down` with cost implications and cleanup best practices + +### 21. Multiple Walkthrough Paths + +- Separate walkthroughs for different user journeys: + - Complete beginners ("Getting Started") + - Existing projects ("Migrate to azd") + - Advanced users ("CI/CD Setup") + - Specific scenarios ("Add Database", "Enable Authentication", "Configure Monitoring") + +### 22. Walkthrough Quick Wins + +- **Action-Oriented Copy**: More engaging titles with expected outcomes +- **Code Samples**: Include example azure.yaml snippets and environment variable configurations +- **Smart Defaults**: Pre-fill common values based on workspace detection +- **Feedback Collection**: Add feedback button and track user drop-off points +- **Accessibility**: Improve alt text and ensure walkthrough works without images + +## Implementation Priority Considerations + +When considering which features to implement, evaluate based on: + +- **User Impact**: Features that solve common pain points +- **Effort vs Value**: Quick wins that provide immediate value +- **Technical Complexity**: Balance complexity with team capacity +- **Integration Needs**: Consider dependencies on Azure services/APIs +- **User Feedback**: Prioritize based on community requests and surveys + +## Next Steps + +1. Review and prioritize features based on user feedback +2. Create detailed specifications for selected features +3. Design user flows and mockups +4. Implement and test in phases +5. Gather feedback and iterate + diff --git a/ext/vscode/README.md b/ext/vscode/README.md index 058669752fc..eee685f4112 100644 --- a/ext/vscode/README.md +++ b/ext/vscode/README.md @@ -2,10 +2,93 @@ This extension makes it easier to run, create Azure Resources, and deploy Azure applications with the Azure Developer CLI. +## Features + +### 🚀 Deployment Commands + +- **Initialize** (`azd init`) - Scaffold a new application from a template +- **Provision** (`azd provision`) - Create Azure infrastructure resources +- **Deploy** (`azd deploy`) - Deploy your application code to Azure +- **Up** (`azd up`) - Provision and deploy in one command +- **Monitor** (`azd monitor`) - View Application Insights for your deployed app +- **Down** (`azd down`) - Delete Azure resources and deployments + +### 📝 Enhanced azure.yaml Editing + +Intelligent editing support for your `azure.yaml` configuration files: + + +- **Auto-Completion** - Smart suggestions for service properties, host types, and lifecycle hooks +- **Hover Documentation** - Inline help with examples for all azure.yaml properties +- **Quick Fixes** - One-click solutions for common issues: + - Create missing project folders + - Add missing language or host properties + - Fix invalid configurations +- **Validation** - Real-time diagnostics for: + - Missing or invalid project paths + - Invalid host types + - Missing recommended properties + - Configuration best practices + +### 🌲 View Panels + +- **My Project** - View your azure.yaml configuration and services +- **Environments** - Manage development, staging, and production environments +- **Template Tools** - Discover and initialize projects from templates + - **Quick Start** (shown when no azure.yaml exists) - Initialize from existing code or create minimal project + - **Browse by Category** - Explore templates organized by type (AI, Web Apps, APIs, Containers, Databases, Functions) + - **AI Templates** - Quick access to AI-focused templates from [aka.ms/aiapps](https://aka.ms/aiapps) + - **Search Templates** - Find templates by name, description, or tags + - **Template Gallery** - Open the full [awesome-azd](https://aka.ms/awesome-azd) gallery in browser +- **Extensions** - Browse and manage Azure Developer CLI extensions +- **Help and Feedback** - Quick access to documentation and support + +### 🔄 Environment Management + +- Create, select, and delete environments +- View environment variables +- Refresh environment configuration from deployments +- Compare environments (coming soon) + +### 🔗 Azure Integration + +- Navigate directly to Azure resources from VS Code +- Open resources in Azure Portal +- View resource connection strings +- Integration with Azure Resources extension + ## What It Does + For more information about Azure Developer CLI and this VS Code extension, please [see the documentation](https://aka.ms/azure-dev/vscode). -## Tell Us What You Think! +## Getting Started + +1. Install the [Azure Developer CLI](https://aka.ms/azure-dev/install) +2. Open a folder containing an `azure.yaml` file, or create a new project with `azd init` +3. Right-click `azure.yaml` and select deployment commands from the context menu +4. Use the Azure Developer CLI view panel for quick access to all features + +## Requirements + +- [Azure Developer CLI](https://aka.ms/azure-dev/install) version 1.0.0 or higher +- [VS Code](https://code.visualstudio.com/) version 1.90.0 or higher + +## Extension Settings + +This extension contributes the following settings: + +- `azure-dev.maximumAppsToDisplay`: Maximum number of Azure Developer CLI apps to display in the Workspace Resource view (default: 5) +- `azure-dev.auth.useIntegratedAuth`: Use VS Code integrated authentication with the Azure Developer CLI (alpha feature) + +## Keyboard Shortcuts + +Access Azure Developer CLI commands quickly: + +- Open Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`) +- Type "Azure Developer CLI" to see all available commands + +## Tell Us What You Think + - [Give us a thumbs up or down](https://aka.ms/azure-dev/hats). We want to hear good news, but bad news are even more important! - Use [Discussions](https://aka.ms/azure-dev/discussions) to share new ideas or ask questions about Azure Developer CLI and the VS Code extension. - To report problems [file an issue](https://aka.ms/azure-dev/issues). diff --git a/ext/vscode/TEST_COVERAGE.md b/ext/vscode/TEST_COVERAGE.md new file mode 100644 index 00000000000..d366a4e85e4 --- /dev/null +++ b/ext/vscode/TEST_COVERAGE.md @@ -0,0 +1,157 @@ +# Test Coverage for PR #6425 + +This document outlines the test coverage added for the VS Code Extension updates and improvements. + +## Overview + +Test files have been created to cover the key features and changes introduced in PR #6425. All tests are located in `src/test/suite/unit/`. + +## Test Files Created + +### 1. environmentsTreeDataProvider.test.ts + +Tests for the new standalone Environments view functionality: + +**Covered Scenarios:** +- ✅ Returns empty array when no applications are found +- ✅ Returns environment items when applications exist +- ✅ Marks default environment with appropriate icon and description +- ✅ Returns environment details when environment node is expanded +- ✅ Returns environment variables when variables group is expanded +- ✅ Toggles environment variable visibility from hidden to visible +- ✅ Toggles environment variable visibility from visible to hidden +- ✅ Does not toggle visibility for non-variable items +- ✅ Fires onDidChangeTreeData event when refresh is called +- ✅ Returns the same tree item passed in for getTreeItem + +**Test Coverage:** +- Environment creation and listing +- Tree item generation and hierarchy +- Refresh operations +- Environment variable visibility toggle + +### 2. extensionsTreeDataProvider.test.ts + +Tests for the Extensions Management view: + +**Covered Scenarios:** +- ✅ Returns empty array when no extensions are installed +- ✅ Returns extension items when extensions are installed +- ✅ Returns empty array for children of extension items +- ✅ Returns the same tree item passed in for getTreeItem +- ✅ Fires onDidChangeTreeData event when refresh is called +- ✅ Creates tree item with correct properties (name, version, icon, contextValue) + +**Test Coverage:** +- Extension listing +- Extension status indicators (version display) +- Tree refresh mechanism + +### 3. openInPortalStep.test.ts + +Tests for the "Show in Azure Portal" command: + +**Covered Scenarios:** +- ✅ Returns true when azureResourceId is present (shouldExecute) +- ✅ Returns false when azureResourceId is missing (shouldExecute) +- ✅ Returns false when azureResourceId is empty string (shouldExecute) +- ✅ Constructs correct portal URL for Web App resource +- ✅ Constructs correct portal URL for Storage Account resource +- ✅ Constructs correct portal URL for Cosmos DB resource +- ✅ Constructs correct portal URL for Resource Group +- ✅ Constructs correct portal URL for Container Apps resource +- ✅ Throws error when azureResourceId is missing +- ✅ Has correct priority value + +**Test Coverage:** +- Portal URL construction for various Azure resource types +- Resource ID handling and parsing +- Command execution flow +- Error handling for missing resource IDs + +### 4. revealStep.test.ts + +Tests for the enhanced resource reveal functionality: + +**Covered Scenarios:** +- ✅ Returns true when azureResourceId is present (shouldExecute) +- ✅ Returns false when azureResourceId is missing (shouldExecute) +- ✅ Returns false when azureResourceId is empty string (shouldExecute) +- ✅ Focuses Azure Resources view before reveal +- ✅ Activates appropriate extension for Microsoft.Web provider +- ✅ Activates appropriate extension for Microsoft.Storage provider +- ✅ Activates appropriate extension for Microsoft.DocumentDB provider +- ✅ Activates appropriate extension for Microsoft.App provider +- ✅ Does not activate extension if already active +- ✅ Attempts to refresh Azure Resources tree +- ✅ Calls revealAzureResource with correct resource ID and options +- ✅ Attempts to reveal resource group first when resource has RG in path +- ✅ Shows error message when reveal fails +- ✅ Shows info message with Copy and Portal options when reveal returns undefined +- ✅ Throws error when azureResourceId is missing +- ✅ Has correct priority value + +**Test Coverage:** +- Resource reveal logic with multiple retry mechanisms +- Automatic extension activation based on resource provider type +- Tree refresh mechanisms before reveal attempts +- Multi-step reveal process (RG first, then resource) +- Error handling with user-friendly fallback options +- Alternative reveal commands when primary method fails + +## PR Testing Checklist Coverage + +Mapping to the original testing checklist in PR #6425: + +| Test Item | Status | Covered By | +|-----------|--------|------------| +| Environment creation from standalone view | ✅ | environmentsTreeDataProvider.test.ts | +| Environment deletion and refresh operations | ✅ | environmentsTreeDataProvider.test.ts | +| Resource group reveal from standalone environments | ✅ | revealStep.test.ts | +| "Show in Azure Portal" command functionality | ✅ | openInPortalStep.test.ts | +| View synchronization after operations | ✅ | environmentsTreeDataProvider.test.ts | +| Extension management operations | ✅ | extensionsTreeDataProvider.test.ts | +| Cross-view command compatibility | ✅ | All test files | +| Error handling and user feedback | ✅ | revealStep.test.ts, openInPortalStep.test.ts | +| Context menu integrations | 🟡 | Partially - covered in logic tests | + +## Running the Tests + +To run the unit tests: + +```bash +npm test +``` + +Or run specific test files: + +```bash +npm test -- --grep "EnvironmentsTreeDataProvider" +npm test -- --grep "ExtensionsTreeDataProvider" +npm test -- --grep "OpenInPortalStep" +npm test -- --grep "RevealStep" +``` + +## Dependencies Added + +- `sinon: ~19` - Mocking library for unit tests +- `@types/sinon: ~17` - TypeScript definitions for sinon + +## Notes + +### Test Framework +Tests use the existing Mocha + Chai framework with Sinon for mocking and stubbing. + +### Stubbing Strategy +- Provider classes are stubbed to isolate unit tests +- VS Code APIs (commands, window, extensions) are stubbed to prevent actual VS Code interactions +- Azure Resource Extension API is mocked with proper type safety + +### Type Safety +All tests are fully typed with proper TypeScript definitions, avoiding `any` types where possible. + +### Future Improvements +- Integration tests for end-to-end workflows +- UI tests for tree view interactions +- Tests for file watcher functionality in EnvironmentsTreeDataProvider +- Performance tests for large numbers of environments/extensions diff --git a/ext/vscode/ext/vscode/package-lock.json b/ext/vscode/ext/vscode/package-lock.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ext/vscode/package-lock.json b/ext/vscode/package-lock.json index 7d54bd2856e..d3f928ab6ab 100644 --- a/ext/vscode/package-lock.json +++ b/ext/vscode/package-lock.json @@ -23,6 +23,7 @@ "@types/mocha": "~10", "@types/node": "~20", "@types/semver": "~7", + "@types/sinon": "~17", "@types/vscode": "~1.90", "@typescript-eslint/eslint-plugin": "~8", "@typescript-eslint/parser": "~8", @@ -33,6 +34,7 @@ "glob": "~11", "mocha": "~11", "node-loader": "~2", + "sinon": "~19", "ts-loader": "~9", "typescript": "~5.9", "webpack": "~5", @@ -1144,6 +1146,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, "node_modules/@textlint/ast-node-types": { "version": "15.2.2", "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz", @@ -1319,6 +1369,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/sinon": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", + "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/vscode": { "version": "1.90.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.90.0.tgz", @@ -1362,7 +1429,6 @@ "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -2052,7 +2118,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2112,7 +2177,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2396,7 +2460,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -3358,7 +3421,6 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4803,6 +4865,13 @@ "setimmediate": "^1.0.5" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, "node_modules/jwa": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", @@ -5449,6 +5518,20 @@ "dev": true, "license": "MIT" }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, "node_modules/node-abi": { "version": "3.77.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz", @@ -6005,6 +6088,17 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -6911,6 +7005,25 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sinon": { + "version": "19.0.5", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.5.tgz", + "integrity": "sha512-r15s9/s+ub/d4bxNXqIUmwp6imVSdTorIRaxoecYjqTVLZ8RuoXr/4EDGwIBo6Waxn7f2gnURX9zuhAfCwaF6Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.5", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", @@ -7520,8 +7633,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tunnel": { "version": "0.0.6", @@ -7560,6 +7672,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/typed-rest-client": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", @@ -7578,7 +7700,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7771,7 +7892,6 @@ "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/ext/vscode/package.json b/ext/vscode/package.json index d8eb573203b..a07bfa091ad 100644 --- a/ext/vscode/package.json +++ b/ext/vscode/package.json @@ -127,6 +127,28 @@ "command": "azure-dev.commands.cli.login", "title": "%azure-dev.commands.cli.login.title%" }, + { + "category": "%azure-dev.commands_category%", + "command": "azure-dev.commands.cli.extension-install", + "title": "Install Extension", + "icon": "$(add)" + }, + { + "category": "%azure-dev.commands_category%", + "command": "azure-dev.commands.cli.extension-uninstall", + "title": "Uninstall Extension" + }, + { + "category": "%azure-dev.commands_category%", + "command": "azure-dev.commands.cli.extension-upgrade", + "title": "Upgrade Extension" + }, + { + "category": "%azure-dev.commands_category%", + "command": "azure-dev.commands.addService", + "title": "Add Service...", + "icon": "$(add)" + }, { "category": "%azure-dev.commands_category%", "command": "azure-dev.commands.getDotEnvFilePath", @@ -142,6 +164,11 @@ "command": "azure-dev.commands.azureWorkspace.revealAzureResourceGroup", "title": "%azure-dev.commands.azureWorkspace.revealAzureResourceGroup.title%" }, + { + "category": "%azure-dev.commands_category%", + "command": "azure-dev.commands.azureWorkspace.showInAzurePortal", + "title": "%azure-dev.commands.azureWorkspace.showInAzurePortal.title%" + }, { "category": "%azure-dev.commands_category%", "command": "azure-dev.commands.enableDevCenterMode", @@ -151,8 +178,69 @@ "category": "%azure-dev.commands_category%", "command": "azure-dev.commands.disableDevCenterMode", "title": "%azure-dev.commands.disableDevCenterMode.title%" + }, + { + "command": "azure-dev.views.environments.toggleVisibility", + "title": "Toggle Visibility" + }, + { + "command": "azure-dev.commands.workspace.toggleVisibility", + "title": "Toggle Visibility" + }, + { + "command": "azure-dev.views.environments.viewDotEnv", + "title": "View .env file", + "icon": "$(go-to-file)" + }, + { + "command": "azure-dev.views.templateTools.openReadme", + "title": "View README", + "icon": "$(book)" + }, + { + "command": "azure-dev.views.templateTools.openGitHub", + "title": "View on GitHub", + "icon": "$(github)" + }, + { + "command": "azure-dev.views.templateTools.initFromTemplateInline", + "title": "Initialize from Template", + "icon": "$(rocket)" } ], + "viewsContainers": { + "activitybar": [ + { + "id": "azure-dev-view", + "title": "Azure Developer CLI", + "icon": "resources/icon.png" + } + ] + }, + "views": { + "azure-dev-view": [ + { + "id": "azure-dev.views.myProject", + "name": "My Project" + }, + { + "id": "azure-dev.views.environments", + "name": "Environments" + }, + { + "id": "azure-dev.views.templateTools", + "name": "Template Tools" + }, + { + "id": "azure-dev.views.extensions", + "name": "Extensions" + }, + { + "id": "azure-dev.views.helpAndFeedback", + "name": "Help and Feedback" + } + ] + }, "configuration": { "title": "Azure Developer CLI", "properties": { @@ -169,11 +257,33 @@ } }, "menus": { + "view/title": [ + { + "command": "azure-dev.commands.cli.env-new", + "when": "view == azure-dev.views.environments", + "group": "navigation" + }, + { + "command": "azure-dev.commands.cli.extension-install", + "when": "view == azure-dev.views.extensions", + "group": "navigation" + }, + { + "command": "azure-dev.views.templateTools.refresh", + "when": "view == azure-dev.views.templateTools", + "group": "navigation@1", + "icon": "$(refresh)" + } + ], "commandPalette": [ { "command": "azure-dev.commands.cli.initFromPom", "when": "false" }, + { + "command": "azure-dev.commands.addService", + "when": "false" + }, { "command": "azure-dev.commands.getDotEnvFilePath", "when": "false" @@ -266,6 +376,31 @@ } ], "view/item/context": [ + { + "command": "azure-dev.views.templateTools.initFromTemplateInline", + "when": "viewItem == template", + "group": "inline@10" + }, + { + "command": "azure-dev.views.templateTools.openGitHub", + "when": "viewItem == template", + "group": "inline@20" + }, + { + "command": "azure-dev.views.templateTools.openReadme", + "when": "viewItem == template", + "group": "10template@10" + }, + { + "command": "azure-dev.views.templateTools.openGitHub", + "when": "viewItem == template", + "group": "10template@20" + }, + { + "command": "azure-dev.commands.addService", + "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.services/i", + "group": "inline@10" + }, { "command": "azure-dev.commands.cli.up", "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.application/i", @@ -316,21 +451,41 @@ "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.environment(?!s)(?!.*default)/i", "group": "20env@20" }, + { + "command": "azure-dev.commands.cli.env-select", + "when": "viewItem =~ /ms-azuretools.azure-dev.views.environments.environment(?!.*default)/i", + "group": "10env@10" + }, { "command": "azure-dev.commands.cli.env-refresh", "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.environment(?!s)/i", "group": "20env@30" }, + { + "command": "azure-dev.commands.cli.env-refresh", + "when": "viewItem =~ /ms-azuretools.azure-dev.views.environments.environment/i", + "group": "10env@20" + }, { "command": "azure-dev.commands.cli.env-edit", "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.environment(?!s)/i", "group": "20env@40" }, + { + "command": "azure-dev.commands.cli.env-edit", + "when": "viewItem =~ /ms-azuretools.azure-dev.views.environments.environment/i", + "group": "10env@30" + }, { "command": "azure-dev.commands.cli.env-delete", "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.environment(?!s)(?!.*default)/i", "group": "20env@50" }, + { + "command": "azure-dev.commands.cli.env-delete", + "when": "viewItem =~ /ms-azuretools.azure-dev.views.environments.environment(?!.*default)/i", + "group": "10env@40" + }, { "command": "azure-dev.commands.cli.restore", "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.(application|services|service(?!s))/i", @@ -356,6 +511,11 @@ "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.service(?!s)/i", "group": "50navigation@10" }, + { + "command": "azure-dev.commands.azureWorkspace.showInAzurePortal", + "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.service(?!s)/i", + "group": "50navigation@20" + }, { "command": "azure-dev.commands.azureWorkspace.revealAzureResourceGroup", "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.application/i", @@ -365,6 +525,26 @@ "command": "azure-dev.commands.azureWorkspace.revealAzureResourceGroup", "when": "viewItem =~ /ms-azuretools.azure-dev.views.workspace.environment(?!s)/i", "group": "50navigation@20" + }, + { + "command": "azure-dev.commands.azureWorkspace.revealAzureResourceGroup", + "when": "viewItem =~ /ms-azuretools.azure-dev.views.environments.environment/i", + "group": "10env@50" + }, + { + "command": "azure-dev.views.environments.viewDotEnv", + "when": "viewItem =~ /ms-azuretools.azure-dev.views.environments.environment/i", + "group": "inline" + }, + { + "command": "azure-dev.commands.cli.extension-upgrade", + "when": "view == azure-dev.views.extensions && viewItem == ms-azuretools.azure-dev.views.extensions.extension", + "group": "10extension@10" + }, + { + "command": "azure-dev.commands.cli.extension-uninstall", + "when": "view == azure-dev.views.extensions && viewItem == ms-azuretools.azure-dev.views.extensions.extension", + "group": "10extension@20" } ] }, @@ -526,6 +706,7 @@ "@types/mocha": "~10", "@types/node": "~20", "@types/semver": "~7", + "@types/sinon": "~17", "@types/vscode": "~1.90", "@typescript-eslint/eslint-plugin": "~8", "@typescript-eslint/parser": "~8", @@ -536,6 +717,7 @@ "glob": "~11", "mocha": "~11", "node-loader": "~2", + "sinon": "~19", "ts-loader": "~9", "typescript": "~5.9", "webpack": "~5", diff --git a/ext/vscode/package.nls.json b/ext/vscode/package.nls.json index b5f4631395b..bee53d34472 100644 --- a/ext/vscode/package.nls.json +++ b/ext/vscode/package.nls.json @@ -21,6 +21,7 @@ "azure-dev.commands.cli.login.title": "Sign in with Azure Developer CLI", "azure-dev.commands.azureWorkspace.revealAzureResource.title": "Show Azure Resource", "azure-dev.commands.azureWorkspace.revealAzureResourceGroup.title": "Show Azure Resource Group", + "azure-dev.commands.azureWorkspace.showInAzurePortal.title": "Show in Azure Portal", "azure-dev.commands.enableDevCenterMode.title": "Enable Dev Center Mode (config set platform.type devcenter)", "azure-dev.commands.disableDevCenterMode.title": "Disable Dev Center Mode (config unset platform.type)", "azure-dev.commands.getDotEnvFilePath.title": "Get Azure developer environment's .env file path", diff --git a/ext/vscode/src/commands/addService.ts b/ext/vscode/src/commands/addService.ts new file mode 100644 index 00000000000..0c822fa9def --- /dev/null +++ b/ext/vscode/src/commands/addService.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import * as yaml from 'yaml'; +import { AzureDevCliModel } from '../views/workspace/AzureDevCliModel'; + +/** + * Adds a new service to the azure.yaml file associated with the given tree item. + * This command is invoked from the Services tree item inline action. + */ +export async function addService(context: IActionContext, node?: AzureDevCliModel): Promise { + let documentUri: vscode.Uri | undefined; + + // Get the azure.yaml file URI from the tree node context + if (node && 'context' in node && node.context.configurationFile) { + documentUri = node.context.configurationFile; + } + + // If no URI was provided via tree node, try to find an azure.yaml in the workspace + if (!documentUri) { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + void vscode.window.showErrorMessage('No workspace folder is open.'); + return; + } + + // Search for azure.yaml or azure.yml files in workspace + const azureYamlFiles = await vscode.workspace.findFiles('**/azure.{yml,yaml}', '**/node_modules/**', 1); + if (azureYamlFiles.length === 0) { + void vscode.window.showErrorMessage('No azure.yaml file found in workspace.'); + return; + } + + documentUri = azureYamlFiles[0]; + } + + // Prompt for service name + const serviceName = await vscode.window.showInputBox({ + prompt: 'Enter service name', + placeHolder: 'api', + validateInput: (value) => { + if (!value || !/^[a-zA-Z0-9-_]+$/.test(value)) { + return 'Service name must contain only letters, numbers, hyphens, and underscores'; + } + return undefined; + } + }); + + if (!serviceName) { + return; + } + + // Prompt for programming language + const language = await vscode.window.showQuickPick( + ['python', 'js', 'ts', 'csharp', 'java', 'go'], + { placeHolder: 'Select programming language' } + ); + + if (!language) { + return; + } + + // Prompt for Azure host + const host = await vscode.window.showQuickPick( + [ + { label: 'containerapp', description: 'Azure Container Apps' }, + { label: 'appservice', description: 'Azure App Service' }, + { label: 'function', description: 'Azure Functions' } + ], + { placeHolder: 'Select Azure host' } + ); + + if (!host) { + return; + } + + try { + const document = await vscode.workspace.openTextDocument(documentUri); + const text = document.getText(); + const doc = yaml.parseDocument(text); + + const services = doc.get('services') as yaml.YAMLMap; + if (!services) { + void vscode.window.showErrorMessage('No services section found in azure.yaml'); + return; + } + + const serviceSnippet = `\n ${serviceName}:\n project: ./${serviceName}\n language: ${language}\n host: ${host.label}`; + + // Find the end of the services section + if (doc.contents && yaml.isMap(doc.contents)) { + const servicesNode = doc.contents.items.find((item) => yaml.isScalar(item.key) && item.key.value === 'services'); + if (servicesNode && servicesNode.value && 'range' in servicesNode.value && servicesNode.value.range) { + const insertPosition = document.positionAt(servicesNode.value.range[1]); + const edit = new vscode.WorkspaceEdit(); + edit.insert(documentUri, insertPosition, serviceSnippet); + const success = await vscode.workspace.applyEdit(edit); + + if (success) { + void vscode.window.showInformationMessage(`Service '${serviceName}' added to azure.yaml`); + } + } + } + } catch (error) { + void vscode.window.showErrorMessage(`Failed to add service: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/ext/vscode/src/commands/azureWorkspace/reveal.ts b/ext/vscode/src/commands/azureWorkspace/reveal.ts index 49b456cb9e1..ef8d3147c92 100644 --- a/ext/vscode/src/commands/azureWorkspace/reveal.ts +++ b/ext/vscode/src/commands/azureWorkspace/reveal.ts @@ -3,22 +3,30 @@ import { AzureWizard, IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; -import { TreeViewModel } from '../../utils/isTreeViewModel'; +import { isTreeViewModel, TreeViewModel } from '../../utils/isTreeViewModel'; import { AzureDevCliApplication } from '../../views/workspace/AzureDevCliApplication'; import { AzureDevCliEnvironment } from '../../views/workspace/AzureDevCliEnvironment'; import { AzureDevCliService } from '../../views/workspace/AzureDevCliService'; +import { EnvironmentItem, EnvironmentTreeItem } from '../../views/environments/EnvironmentsTreeDataProvider'; import { PickEnvironmentStep } from './wizard/PickEnvironmentStep'; import { PickResourceGroupStep, RevealResourceGroupWizardContext } from './wizard/PickResourceGroupStep'; import { PickResourceStep, RevealResourceWizardContext } from './wizard/PickResourceStep'; import { RevealStep } from './wizard/RevealStep'; +import { OpenInPortalStep } from './wizard/OpenInPortalStep'; -export async function revealAzureResource(context: IActionContext, treeItem: TreeViewModel): Promise { - const selectedItem = treeItem.unwrap(); +export async function revealAzureResource(context: IActionContext, treeItem?: TreeViewModel | AzureDevCliService): Promise { + console.log('[revealAzureResource] Starting...'); + if (!treeItem) { + throw new Error(vscode.l10n.t('This command must be run from a service item in the Azure Developer CLI view')); + } + + const selectedItem = isTreeViewModel(treeItem) ? treeItem.unwrap() : treeItem; context.telemetry.properties.revealSource = selectedItem.constructor.name; const wizardContext = context as RevealResourceWizardContext; wizardContext.configurationFile = selectedItem.context.configurationFile; wizardContext.service = selectedItem.name; + console.log('[revealAzureResource] Service:', selectedItem.name, 'ConfigFile:', selectedItem.context.configurationFile.fsPath); const wizard = new AzureWizard(context, { @@ -34,19 +42,42 @@ export async function revealAzureResource(context: IActionContext, treeItem: Tre } ); + console.log('[revealAzureResource] Starting wizard.prompt()...'); await wizard.prompt(); + console.log('[revealAzureResource] wizard.prompt() completed'); + + console.log('[revealAzureResource] Starting wizard.execute()...'); await wizard.execute(); + console.log('[revealAzureResource] wizard.execute() completed'); } -export async function revealAzureResourceGroup(context: IActionContext, treeItem: TreeViewModel): Promise { - const selectedItem = treeItem.unwrap(); - context.telemetry.properties.revealSource = selectedItem.constructor.name; +export async function revealAzureResourceGroup(context: IActionContext, treeItem?: TreeViewModel | AzureDevCliApplication | AzureDevCliEnvironment | EnvironmentTreeItem): Promise { + if (!treeItem) { + throw new Error(vscode.l10n.t('This command must be run from an application or environment item in the Azure Developer CLI view')); + } + + let configurationFile: vscode.Uri; + let environmentName: string | undefined; + + if (treeItem instanceof EnvironmentTreeItem) { + const data = treeItem.data as EnvironmentItem; + configurationFile = data.configurationFile; + environmentName = data.name; + } else { + const selectedItem = isTreeViewModel(treeItem) ? treeItem.unwrap() : treeItem; + context.telemetry.properties.revealSource = selectedItem.constructor.name; + + configurationFile = selectedItem.context.configurationFile; + if (selectedItem instanceof AzureDevCliEnvironment) { + environmentName = selectedItem.name; + } + } const wizardContext = context as RevealResourceGroupWizardContext; - wizardContext.configurationFile = selectedItem.context.configurationFile; + wizardContext.configurationFile = configurationFile; - if (selectedItem instanceof AzureDevCliEnvironment) { - wizardContext.environment = selectedItem.name; + if (environmentName) { + wizardContext.environment = environmentName; } const wizard = new AzureWizard(context, @@ -63,6 +94,48 @@ export async function revealAzureResourceGroup(context: IActionContext, treeItem } ); + console.log('[revealAzureResourceGroup] Starting wizard.prompt()...'); await wizard.prompt(); + console.log('[revealAzureResourceGroup] wizard.prompt() completed'); + + console.log('[revealAzureResourceGroup] Starting wizard.execute()...'); + await wizard.execute(); + console.log('[revealAzureResourceGroup] wizard.execute() completed'); +} + +export async function showInAzurePortal(context: IActionContext, treeItem?: TreeViewModel | AzureDevCliService): Promise { + console.log('[showInAzurePortal] Starting...'); + if (!treeItem) { + throw new Error(vscode.l10n.t('This command must be run from a service item in the Azure Developer CLI view')); + } + + const selectedItem = isTreeViewModel(treeItem) ? treeItem.unwrap() : treeItem; + context.telemetry.properties.showInPortalSource = selectedItem.constructor.name; + + const wizardContext = context as RevealResourceWizardContext; + wizardContext.configurationFile = selectedItem.context.configurationFile; + wizardContext.service = selectedItem.name; + console.log('[showInAzurePortal] Service:', selectedItem.name, 'ConfigFile:', selectedItem.context.configurationFile.fsPath); + + const wizard = new AzureWizard(context, + { + title: vscode.l10n.t('Show in Azure Portal'), + promptSteps: [ + new PickEnvironmentStep(), + new PickResourceStep(), + ], + executeSteps: [ + new OpenInPortalStep(), + ], + hideStepCount: true, + } + ); + + console.log('[showInAzurePortal] Starting wizard.prompt()...'); + await wizard.prompt(); + console.log('[showInAzurePortal] wizard.prompt() completed'); + + console.log('[showInAzurePortal] Starting wizard.execute()...'); await wizard.execute(); + console.log('[showInAzurePortal] wizard.execute() completed'); } diff --git a/ext/vscode/src/commands/azureWorkspace/wizard/OpenInPortalStep.ts b/ext/vscode/src/commands/azureWorkspace/wizard/OpenInPortalStep.ts new file mode 100644 index 00000000000..71d430062fe --- /dev/null +++ b/ext/vscode/src/commands/azureWorkspace/wizard/OpenInPortalStep.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { AzureWizardExecuteStep, nonNullProp } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { RevealResourceGroupWizardContext } from './PickResourceGroupStep'; +import { RevealResourceWizardContext } from './PickResourceStep'; + +export class OpenInPortalStep extends AzureWizardExecuteStep { + public readonly priority: number = 100; + + public shouldExecute(wizardContext: RevealResourceWizardContext | RevealResourceGroupWizardContext): boolean { + const should = !!wizardContext.azureResourceId; + console.log('[OpenInPortalStep] shouldExecute:', should, 'azureResourceId:', wizardContext.azureResourceId); + return should; + } + + public async execute(context: RevealResourceWizardContext | RevealResourceGroupWizardContext): Promise { + console.log('[OpenInPortalStep] Starting execute with azureResourceId:', context.azureResourceId); + const azureResourceId = nonNullProp(context, 'azureResourceId'); + + // Construct the Azure Portal URL for the resource + const portalUrl = `https://portal.azure.com/#@/resource${azureResourceId}`; + console.log('[OpenInPortalStep] Opening portal URL:', portalUrl); + + // Open the URL in the default browser + await vscode.env.openExternal(vscode.Uri.parse(portalUrl)); + console.log('[OpenInPortalStep] Portal URL opened successfully'); + } +} diff --git a/ext/vscode/src/commands/azureWorkspace/wizard/PickEnvironmentStep.ts b/ext/vscode/src/commands/azureWorkspace/wizard/PickEnvironmentStep.ts index db6c7f73d35..3aa8d8743ae 100644 --- a/ext/vscode/src/commands/azureWorkspace/wizard/PickEnvironmentStep.ts +++ b/ext/vscode/src/commands/azureWorkspace/wizard/PickEnvironmentStep.ts @@ -23,7 +23,9 @@ export class PickEnvironmentStep extends SkipIfOneStep { + console.log('[PickEnvironmentStep] Starting prompt...'); context.environment = await this.promptInternal(context); + console.log('[PickEnvironmentStep] Selected environment:', context.environment); } public shouldPrompt(context: RevealWizardContext): boolean { @@ -40,4 +42,4 @@ export class PickEnvironmentStep extends SkipIfOneStep { - context.azureResourceId = await this.promptInternal(context); + try { + context.azureResourceId = await this.promptInternal(context); + } catch (error) { + // Ensure error is shown to user + console.error('[PickResourceGroupStep] Error during prompt:', error); + if (error instanceof Error) { + await vscode.window.showErrorMessage(error.message); + } + throw error; + } } public shouldPrompt(context: RevealResourceGroupWizardContext): boolean { @@ -33,7 +42,8 @@ export class PickResourceGroupStep extends SkipIfOneStep[]> { - const showResults = await this.showProvider.getShowResults(context, context.configurationFile, context.environment); + try { + const showResults = await this.showProvider.getShowResults(context, context.configurationFile, context.environment); if (!showResults?.services && !showResults?.resources) { return []; @@ -76,5 +86,11 @@ export class PickResourceGroupStep extends SkipIfOneStep { - context.azureResourceId = await this.promptInternal(context); + console.log('[PickResourceStep] Starting prompt for service:', context.service); + try { + context.azureResourceId = await this.promptInternal(context); + console.log('[PickResourceStep] Selected resource:', context.azureResourceId); + } catch (error) { + // Log the error and let the wizard framework handle user-facing error display + console.error('[PickResourceStep] Error during prompt:', error); + throw error; + } } public shouldPrompt(context: RevealResourceWizardContext): boolean { - return !context.azureResourceId; + const shouldPrompt = !context.azureResourceId; + console.log('[PickResourceStep] shouldPrompt:', shouldPrompt); + return shouldPrompt; } protected override async getPicks(context: RevealResourceWizardContext): Promise[]> { - const showResults = await this.showProvider.getShowResults(context, context.configurationFile, context.environment); + console.log('[PickResourceStep] getPicks called for service:', context.service, 'environment:', context.environment); + try { + const showResults = await this.showProvider.getShowResults(context, context.configurationFile, context.environment); + console.log('[PickResourceStep] showResults received:', !!showResults, 'services:', Object.keys(showResults?.services || {})); - if (!showResults?.services?.[context.service]?.target?.resourceIds) { - return []; - } + if (!showResults?.services?.[context.service]?.target?.resourceIds) { + console.log('[PickResourceStep] No resourceIds found for service:', context.service); + return []; + } - return showResults.services[context.service].target.resourceIds.map(resourceId => { - const { resourceName, provider } = parseAzureResourceId(resourceId); - return { - label: resourceName!, - detail: provider, // TODO: do we want to show provider? - data: resourceId - }; - }); + const resourceIds = showResults.services[context.service].target.resourceIds; + console.log('[PickResourceStep] Found', resourceIds.length, 'resources for service:', context.service); + return resourceIds.map(resourceId => { + const { resourceName, provider } = parseAzureResourceId(resourceId); + return { + label: resourceName!, + detail: provider, // TODO: do we want to show provider? + data: resourceId + }; + }); + } catch (error) { + // Log the error for diagnostics + console.error('[PickResourceStep] Failed to get resources:', error); + // Re-throw to let the wizard handle it + throw error; + } } } diff --git a/ext/vscode/src/commands/azureWorkspace/wizard/RevealStep.ts b/ext/vscode/src/commands/azureWorkspace/wizard/RevealStep.ts index eb7d803b834..1feb5c9f73f 100644 --- a/ext/vscode/src/commands/azureWorkspace/wizard/RevealStep.ts +++ b/ext/vscode/src/commands/azureWorkspace/wizard/RevealStep.ts @@ -2,6 +2,8 @@ // Licensed under the MIT License. import { AzureWizardExecuteStep, nonNullProp } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import ext from '../../../ext'; import { getAzureResourceExtensionApi } from '../../../utils/getAzureResourceExtensionApi'; import { RevealResourceGroupWizardContext } from './PickResourceGroupStep'; import { RevealResourceWizardContext } from './PickResourceStep'; @@ -10,12 +12,129 @@ export class RevealStep extends AzureWizardExecuteStep { + ext.outputChannel.appendLog(vscode.l10n.t('RevealStep starting execute with azureResourceId: {0}', context.azureResourceId || 'undefined')); const azureResourceId = nonNullProp(context, 'azureResourceId'); + ext.outputChannel.appendLog(vscode.l10n.t('Getting Azure Resource Extension API...')); const api = await getAzureResourceExtensionApi(); - await api.resources.revealAzureResource(azureResourceId, { select: true, focus: true, expand: true }); + ext.outputChannel.appendLog(vscode.l10n.t('API obtained, focusing Azure Resources view...')); + + // Show the Azure Resources view first to ensure the reveal is visible + await vscode.commands.executeCommand('azureResourceGroups.focus'); + ext.outputChannel.appendLog(vscode.l10n.t('View focused')); + + // Extract provider from resource ID to determine which extension to activate + const providerMatch = azureResourceId.match(/\/providers\/([^/]+)/i); + const provider = providerMatch ? providerMatch[1] : null; + ext.outputChannel.appendLog(vscode.l10n.t('Resource provider: {0}', provider || 'none')); + + // Activate the appropriate Azure extension based on provider + if (provider) { + const extensionMap: Record = { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Microsoft.App': 'ms-azuretools.vscode-azurecontainerapps', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Microsoft.Web': 'ms-azuretools.vscode-azurefunctions', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Microsoft.Storage': 'ms-azuretools.vscode-azurestorage', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Microsoft.DocumentDB': 'ms-azuretools.azure-cosmos', + }; + + const extensionId = extensionMap[provider]; + if (extensionId) { + ext.outputChannel.appendLog(vscode.l10n.t('Activating extension: {0}', extensionId)); + const extension = vscode.extensions.getExtension(extensionId); + if (extension && !extension.isActive) { + await extension.activate(); + ext.outputChannel.appendLog(vscode.l10n.t('Extension activated')); + // Give it time to register its tree data provider + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + } + + ext.outputChannel.appendLog(vscode.l10n.t('Attempting reveal...')); + + try { + // Try to refresh the Azure Resources view to ensure the tree is loaded + ext.outputChannel.appendLog(vscode.l10n.t('Refreshing Azure Resources tree...')); + try { + await vscode.commands.executeCommand('azureResourceGroups.refresh'); + ext.outputChannel.appendLog(vscode.l10n.t('Refresh command executed')); + await new Promise(resolve => setTimeout(resolve, 1500)); + } catch (refreshError) { + ext.outputChannel.appendLog(vscode.l10n.t('Refresh command not available or failed: {0}', refreshError instanceof Error ? refreshError.message : String(refreshError))); + } + + // Extract subscription and resource group from the resource ID to reveal the RG first + const resourceIdMatch = azureResourceId.match(/\/subscriptions\/([^/]+)\/resourceGroups\/([^/]+)/i); + if (resourceIdMatch) { + const subscriptionId = resourceIdMatch[1]; + const resourceGroupName = resourceIdMatch[2]; + ext.outputChannel.appendLog(vscode.l10n.t('Subscription: {0}, Resource Group: {1}', subscriptionId, resourceGroupName)); + + // Try revealing the resource group first to ensure the tree is expanded + const rgResourceId = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}`; + ext.outputChannel.appendLog(vscode.l10n.t('Revealing resource group first: {0}', rgResourceId)); + try { + await api.resources.revealAzureResource(rgResourceId, { select: false, focus: false, expand: true }); + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (rgError) { + ext.outputChannel.appendLog(vscode.l10n.t('Resource group reveal failed: {0}', rgError instanceof Error ? rgError.message : String(rgError))); + } + } + + ext.outputChannel.appendLog(vscode.l10n.t('Calling revealAzureResource with options: select=true, focus=true, expand=true')); + const result = await api.resources.revealAzureResource(azureResourceId, { select: true, focus: true, expand: true }); + ext.outputChannel.appendLog(vscode.l10n.t('revealAzureResource returned: {0}', String(result))); + + // Note: The focusGroup command to trigger "Focused Resources" view requires internal + // tree item context that's not accessible through the public API. Users can manually + // click the zoom-in icon on the resource group if they want the focused view. + + // Try a second time if needed + if (result === undefined) { + ext.outputChannel.appendLog(vscode.l10n.t('First reveal returned undefined, trying again after delay...')); + await new Promise(resolve => setTimeout(resolve, 1000)); + const secondResult = await api.resources.revealAzureResource(azureResourceId, { select: true, focus: true, expand: true }); + ext.outputChannel.appendLog(vscode.l10n.t('Second attempt returned: {0}', String(secondResult))); + + // Try using the openInPortal command as an alternative + if (secondResult === undefined) { + ext.outputChannel.appendLog(vscode.l10n.t('Reveal API not working as expected, trying alternative approach')); + // Try the workspace resource reveal command specific to this view + try { + await vscode.commands.executeCommand('azureResourceGroups.revealResource', azureResourceId); + ext.outputChannel.appendLog(vscode.l10n.t('Alternative reveal command succeeded')); + } catch (altError) { + ext.outputChannel.appendLog(vscode.l10n.t('Alternative reveal also failed: {0}', altError instanceof Error ? altError.message : String(altError))); + vscode.window.showInformationMessage( + vscode.l10n.t('Unable to automatically reveal resource in tree. Resource ID: {0}', azureResourceId), + vscode.l10n.t('Copy Resource ID'), + vscode.l10n.t('Open in Portal') + ).then(async selection => { + if (selection === vscode.l10n.t('Copy Resource ID')) { + await vscode.env.clipboard.writeText(azureResourceId); + } else if (selection === vscode.l10n.t('Open in Portal')) { + await vscode.commands.executeCommand('azureResourceGroups.openInPortal', azureResourceId); + } + }); + } + } + } + + ext.outputChannel.appendLog(vscode.l10n.t('revealAzureResource completed')); + } catch (error) { + ext.outputChannel.appendLog(vscode.l10n.t('Failed to reveal resource: {0}', error instanceof Error ? error.message : String(error))); + // Show error to user + vscode.window.showErrorMessage(vscode.l10n.t('Failed to reveal Azure resource: {0}', error instanceof Error ? error.message : String(error))); + throw error; + } } -} \ No newline at end of file +} diff --git a/ext/vscode/src/commands/deploy.ts b/ext/vscode/src/commands/deploy.ts index 1020634c95e..d0362d2ff87 100644 --- a/ext/vscode/src/commands/deploy.ts +++ b/ext/vscode/src/commands/deploy.ts @@ -7,14 +7,24 @@ import * as vscode from 'vscode'; import { TelemetryId } from '../telemetry/telemetryId'; import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; -import { isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; +import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliModel } from '../views/workspace/AzureDevCliModel'; import { AzureDevCliService } from '../views/workspace/AzureDevCliService'; import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; export async function deploy(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { - const selectedModel = isTreeViewModel(selectedItem) ? selectedItem.unwrap() : undefined; - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; + let selectedModel: AzureDevCliModel | undefined; + let selectedFile: vscode.Uri | undefined; + + if (isTreeViewModel(selectedItem)) { + selectedModel = selectedItem.unwrap(); + selectedFile = selectedModel.context.configurationFile; + } else if (isAzureDevCliModel(selectedItem)) { + selectedModel = selectedItem; + selectedFile = selectedModel.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/commands/down.ts b/ext/vscode/src/commands/down.ts index 15932094edb..a937c04564d 100644 --- a/ext/vscode/src/commands/down.ts +++ b/ext/vscode/src/commands/down.ts @@ -7,7 +7,7 @@ import * as vscode from 'vscode'; import { TelemetryId } from '../telemetry/telemetryId'; import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; -import { isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; +import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliApplication } from '../views/workspace/AzureDevCliApplication'; import { getAzDevTerminalTitle, getWorkingFolder, } from './cmdUtil'; @@ -19,7 +19,14 @@ export type DownCommandArguments = [ vscode.Uri | TreeViewModel | undefined, boo export async function down(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel, fromAgent: boolean = false): Promise { context.telemetry.properties.fromAgent = fromAgent.toString(); - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; + let selectedFile: vscode.Uri | undefined; + if (isTreeViewModel(selectedItem)) { + selectedFile = selectedItem.unwrap().context.configurationFile; + } else if (isAzureDevCliModel(selectedItem)) { + selectedFile = selectedItem.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } const workingFolder = await getWorkingFolder(context, selectedFile); const confirmPrompt = vscode.l10n.t("Are you sure you want to delete all this application's Azure resources? You can soft-delete certain resources like Azure KeyVaults to preserve their data, or permanently delete and purge them."); diff --git a/ext/vscode/src/commands/env.ts b/ext/vscode/src/commands/env.ts index 0c872abd56d..98114354fad 100644 --- a/ext/vscode/src/commands/env.ts +++ b/ext/vscode/src/commands/env.ts @@ -9,34 +9,60 @@ import { TelemetryId } from '../telemetry/telemetryId'; import { createAzureDevCli } from '../utils/azureDevCli'; import { execAsync } from '../utils/execAsync'; import { executeAsTask } from '../utils/executeAsTask'; -import { isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; +import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { quickPickWorkspaceFolder } from '../utils/quickPickWorkspaceFolder'; import { AzureDevCliEnvironments } from '../views/workspace/AzureDevCliEnvironments'; import { AzureDevCliEnvironment } from '../views/workspace/AzureDevCliEnvironment'; +import { EnvironmentItem, EnvironmentTreeItem } from '../views/environments/EnvironmentsTreeDataProvider'; import { EnvironmentInfo, getAzDevTerminalTitle, getEnvironments } from './cmdUtil'; -export async function editEnvironment(context: IActionContext, selectedEnvironment?: TreeViewModel): Promise { +export async function editEnvironment(context: IActionContext, selectedEnvironment?: TreeViewModel | EnvironmentTreeItem): Promise { if (selectedEnvironment) { - const environment = selectedEnvironment.unwrap(); - - if (environment.environmentFile) { - const document = await vscode.workspace.openTextDocument(environment.environmentFile); + let environmentFile: vscode.Uri | undefined; + + if (selectedEnvironment instanceof EnvironmentTreeItem) { + const data = selectedEnvironment.data as EnvironmentItem; + environmentFile = data.dotEnvPath ? vscode.Uri.file(data.dotEnvPath) : undefined; + } else { + const environment = selectedEnvironment.unwrap(); + environmentFile = environment.environmentFile; + } + if (environmentFile) { + const document = await vscode.workspace.openTextDocument(environmentFile); await vscode.window.showTextDocument(document); } } } -export async function deleteEnvironment(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { - const selectedEnvironment = isTreeViewModel(selectedItem) ? selectedItem.unwrap() : undefined; - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; +export async function deleteEnvironment(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel | EnvironmentTreeItem): Promise { + let selectedEnvironment: AzureDevCliEnvironment | undefined; + let selectedFile: vscode.Uri | undefined; + let environmentName: string | undefined; + + if (isTreeViewModel(selectedItem)) { + selectedEnvironment = selectedItem.unwrap(); + selectedFile = selectedItem.unwrap().context.configurationFile; + } else if (selectedItem instanceof AzureDevCliEnvironment) { + selectedEnvironment = selectedItem; + selectedFile = selectedItem.context.configurationFile; + } else if (selectedItem instanceof EnvironmentTreeItem) { + const data = selectedItem.data as EnvironmentItem; + selectedFile = data.configurationFile; + environmentName = data.name; + } else if (isAzureDevCliModel(selectedItem)) { + selectedFile = selectedItem.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } + let folder: vscode.WorkspaceFolder | undefined = (selectedFile ? vscode.workspace.getWorkspaceFolder(selectedFile) : undefined); if (!folder) { folder = await quickPickWorkspaceFolder(context, vscode.l10n.t("To run '{0}' command you must first open a folder or workspace in VS Code", 'env select')); } const cwd = folder.uri.fsPath; - let name = selectedEnvironment?.name; + let name = selectedEnvironment?.name ?? environmentName; if (!name) { let envData: EnvironmentInfo[] = []; @@ -88,19 +114,43 @@ export async function deleteEnvironment(context: IActionContext, selectedItem?: if (selectedEnvironment) { selectedEnvironment?.context.refreshEnvironments(); } + + // Refresh standalone environments view + void vscode.commands.executeCommand('azure-dev.views.environments.refresh'); + + // Refresh workspace resource view + void vscode.commands.executeCommand('azureWorkspace.refresh'); } } -export async function selectEnvironment(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { - const selectedEnvironment = isTreeViewModel(selectedItem) ? selectedItem.unwrap() : undefined; - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; +export async function selectEnvironment(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel | EnvironmentTreeItem): Promise { + let selectedEnvironment: AzureDevCliEnvironment | undefined; + let selectedFile: vscode.Uri | undefined; + let environmentName: string | undefined; + + if (isTreeViewModel(selectedItem)) { + selectedEnvironment = selectedItem.unwrap(); + selectedFile = selectedItem.unwrap().context.configurationFile; + } else if (selectedItem instanceof AzureDevCliEnvironment) { + selectedEnvironment = selectedItem; + selectedFile = selectedItem.context.configurationFile; + } else if (selectedItem instanceof EnvironmentTreeItem) { + const data = selectedItem.data as EnvironmentItem; + selectedFile = data.configurationFile; + environmentName = data.name; + } else if (isAzureDevCliModel(selectedItem)) { + selectedFile = selectedItem.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } + let folder: vscode.WorkspaceFolder | undefined = (selectedFile ? vscode.workspace.getWorkspaceFolder(selectedFile) : undefined); if (!folder) { folder = await quickPickWorkspaceFolder(context, vscode.l10n.t("To run '{0}' command you must first open a folder or workspace in VS Code", 'env select')); } const cwd = folder.uri.fsPath; - let name = selectedEnvironment?.name; + let name = selectedEnvironment?.name ?? environmentName; if (!name) { let envData: EnvironmentInfo[] = []; @@ -154,38 +204,134 @@ export async function selectEnvironment(context: IActionContext, selectedItem?: void vscode.window.showInformationMessage( vscode.l10n.t("'{0}' is now the default environment.", name)); + // Refresh workspace environments view if (selectedEnvironment) { selectedEnvironment?.context.refreshEnvironments(); } + + // Refresh standalone environments view + void vscode.commands.executeCommand('azure-dev.views.environments.refresh'); + + // Refresh workspace resource view + void vscode.commands.executeCommand('azureWorkspace.refresh'); } -export async function newEnvironment(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { - const environmentsNode = isTreeViewModel(selectedItem) ? selectedItem.unwrap() : undefined; - const selectedFile = environmentsNode?.context.configurationFile ?? selectedItem as vscode.Uri; +export async function newEnvironment(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel | EnvironmentTreeItem): Promise { + let environmentsNode: AzureDevCliEnvironments | undefined; + let selectedFile: vscode.Uri | undefined; + + if (isTreeViewModel(selectedItem)) { + environmentsNode = selectedItem.unwrap(); + selectedFile = environmentsNode.context.configurationFile; + } else if (selectedItem instanceof AzureDevCliEnvironments) { + environmentsNode = selectedItem; + selectedFile = selectedItem.context.configurationFile; + } else if (selectedItem instanceof EnvironmentTreeItem) { + const data = selectedItem.data as EnvironmentItem; + selectedFile = data.configurationFile; + } else if (isAzureDevCliModel(selectedItem)) { + selectedFile = selectedItem.context.configurationFile; + } else if (selectedItem instanceof vscode.Uri) { + selectedFile = selectedItem; + } + let folder: vscode.WorkspaceFolder | undefined = (selectedFile ? vscode.workspace.getWorkspaceFolder(selectedFile) : undefined); if (!folder) { folder = await quickPickWorkspaceFolder(context, vscode.l10n.t("To run '{0}' command you must first open a folder or workspace in VS Code", 'env new')); } + // Get current environment + let currentEnv: string | undefined; + try { + const envs = await getEnvironments(context, folder.uri.fsPath); + currentEnv = envs.find(e => e.IsDefault)?.Name; + } catch { + // Ignore error, maybe no environments yet + } + + const name = await vscode.window.showInputBox({ + prompt: vscode.l10n.t('Enter the name of the new environment'), + placeHolder: vscode.l10n.t('Environment name'), + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return vscode.l10n.t('Name cannot be empty'); + } + return undefined; + } + }); + + if (!name) { + return; + } + + let setAsCurrent = true; + if (currentEnv) { + const yesItem: IAzureQuickPickItem = { label: vscode.l10n.t('Yes'), data: true }; + const noItem: IAzureQuickPickItem = { label: vscode.l10n.t('No'), data: false }; + const result = await context.ui.showQuickPick([yesItem, noItem], { + placeHolder: vscode.l10n.t('Set the new environment as the current environment?'), + suppressPersistence: true + }); + setAsCurrent = result.data; + } + const azureCli = await createAzureDevCli(context); const args = composeArgs( withArg('env', 'new'), + withQuotedArg(name), + withArg('--no-prompt') )(); - void executeAsTask(azureCli.invocation, args, getAzDevTerminalTitle(), azureCli.spawnOptions(folder.uri.fsPath), { + await executeAsTask(azureCli.invocation, args, getAzDevTerminalTitle(), azureCli.spawnOptions(folder.uri.fsPath), { focus: true, alwaysRunNew: true, workspaceFolder: folder, - }, TelemetryId.EnvNewCli).then(() => { - if (environmentsNode) { - environmentsNode.context.refreshEnvironments(); + }, TelemetryId.EnvNewCli); + + if (!setAsCurrent && currentEnv) { + const selectArgs = composeArgs( + withArg('env', 'select'), + withQuotedArg(currentEnv), + )(); + try { + await execAsync(azureCli.invocation, selectArgs, azureCli.spawnOptions(folder.uri.fsPath)); + } catch (err) { + void vscode.window.showErrorMessage(vscode.l10n.t('Failed to switch back to environment "{0}": {1}', currentEnv, parseError(err).message)); } - }); + } + + if (environmentsNode) { + environmentsNode.context.refreshEnvironments(); + } + + // Refresh standalone environments view + void vscode.commands.executeCommand('azure-dev.views.environments.refresh'); + + // Refresh workspace resource view + void vscode.commands.executeCommand('azureWorkspace.refresh'); } -export async function refreshEnvironment(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { - const selectedEnvironment = isTreeViewModel(selectedItem) ? selectedItem.unwrap() : undefined; - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; +export async function refreshEnvironment(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel | EnvironmentTreeItem): Promise { + let selectedEnvironment: AzureDevCliEnvironment | undefined; + let selectedFile: vscode.Uri | undefined; + let environmentName: string | undefined; + + if (isTreeViewModel(selectedItem)) { + selectedEnvironment = selectedItem.unwrap(); + selectedFile = selectedItem.unwrap().context.configurationFile; + } else if (selectedItem instanceof AzureDevCliEnvironment) { + selectedEnvironment = selectedItem; + selectedFile = selectedItem.context.configurationFile; + } else if (selectedItem instanceof EnvironmentTreeItem) { + const data = selectedItem.data as EnvironmentItem; + selectedFile = data.configurationFile; + environmentName = data.name; + } else if (isAzureDevCliModel(selectedItem)) { + selectedFile = selectedItem.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } + let folder: vscode.WorkspaceFolder | undefined = (selectedFile ? vscode.workspace.getWorkspaceFolder(selectedFile) : undefined); if (!folder) { folder = await quickPickWorkspaceFolder(context, vscode.l10n.t("To run '{0}' command you must first open a folder or workspace in VS Code", 'env refresh')); @@ -194,7 +340,7 @@ export async function refreshEnvironment(context: IActionContext, selectedItem?: const azureCli = await createAzureDevCli(context); const args = composeArgs( withArg('env', 'refresh'), - withNamedArg('--environment', selectedEnvironment?.name, { shouldQuote: true }), + withNamedArg('--environment', selectedEnvironment?.name ?? environmentName, { shouldQuote: true }), )(); void executeAsTask(azureCli.invocation, args, getAzDevTerminalTitle(), azureCli.spawnOptions(folder.uri.fsPath), { @@ -205,6 +351,10 @@ export async function refreshEnvironment(context: IActionContext, selectedItem?: if (selectedEnvironment) { selectedEnvironment.context.refreshEnvironments(); } + // Refresh standalone environments view + void vscode.commands.executeCommand('azure-dev.views.environments.refresh'); + // Refresh workspace resource view + void vscode.commands.executeCommand('azureWorkspace.refresh'); }); } diff --git a/ext/vscode/src/commands/extensions.ts b/ext/vscode/src/commands/extensions.ts new file mode 100644 index 00000000000..ff24818a2a3 --- /dev/null +++ b/ext/vscode/src/commands/extensions.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as vscode from 'vscode'; +import { IActionContext } from '@microsoft/vscode-azext-utils'; +import { CommandLineArgs, composeArgs, withArg, withQuotedArg } from '@microsoft/vscode-processutils'; +import { createAzureDevCli } from '../utils/azureDevCli'; +import { execAsync } from '../utils/execAsync'; +import { TelemetryId } from '../telemetry/telemetryId'; +import { ExtensionTreeItem } from '../views/extensions/ExtensionsTreeDataProvider'; + +async function runExtensionCommand(context: IActionContext, args: CommandLineArgs, title: string, telemetryId: TelemetryId): Promise { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: title, + cancellable: false + }, async () => { + const azureCli = await createAzureDevCli(context); + try { + const result = await execAsync(azureCli.invocation, args, azureCli.spawnOptions()); + const output = result.stdout + result.stderr; + + // Parse output for status messages + const lines = output.split('\n'); + let message = vscode.l10n.t('Command completed successfully.'); + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.includes('Skipped:')) { + message = trimmed.replace('(-)', '').trim(); + } else if (trimmed.includes('Installed') && !trimmed.includes('SUCCESS')) { + message = trimmed; + } else if (trimmed.includes('Upgraded')) { + message = trimmed; + } else if (trimmed.includes('Uninstalled')) { + message = trimmed; + } + } + + void vscode.window.showInformationMessage(message); + } catch (error) { + void vscode.window.showErrorMessage(vscode.l10n.t('Command failed: {0}', (error as Error).message)); + } + }); + + void vscode.commands.executeCommand('azure-dev.views.extensions.refresh'); +} + +export async function installExtension(context: IActionContext): Promise { + const registryName = await vscode.window.showInputBox({ + prompt: vscode.l10n.t('Enter the registry name (optional)'), + placeHolder: vscode.l10n.t('Registry Name (Press Enter to skip)') + }); + + if (registryName) { + const location = await vscode.window.showInputBox({ + prompt: vscode.l10n.t('Enter the registry location (URL)'), + placeHolder: vscode.l10n.t('https://...') + }); + + if (!location) { + return; + } + + const args = composeArgs( + withArg('extension', 'source', 'add'), + withArg('--name', registryName), + withArg('--location', location) + )(); + + await runExtensionCommand(context, args, vscode.l10n.t('Adding extension source...'), TelemetryId.ExtensionSourceAddCli); + } + + const id = await vscode.window.showInputBox({ + prompt: vscode.l10n.t('Enter the ID of the extension to install'), + placeHolder: vscode.l10n.t('Extension ID') + }); + + if (!id) { + return; + } + + const args = composeArgs( + withArg('extension', 'install'), + withQuotedArg(id) + )(); + + await runExtensionCommand(context, args, vscode.l10n.t('Installing extension...'), TelemetryId.ExtensionInstallCli); +} + +export async function uninstallExtension(context: IActionContext, item?: ExtensionTreeItem): Promise { + let id = item?.extension.id; + + if (!id) { + id = await vscode.window.showInputBox({ + prompt: vscode.l10n.t('Enter the ID of the extension to uninstall'), + placeHolder: vscode.l10n.t('Extension ID') + }); + } + + if (!id) { + return; + } + + const args = composeArgs( + withArg('extension', 'uninstall'), + withQuotedArg(id) + )(); + + await runExtensionCommand(context, args, vscode.l10n.t('Uninstalling extension...'), TelemetryId.ExtensionUninstallCli); +} + +export async function upgradeExtension(context: IActionContext, item?: ExtensionTreeItem): Promise { + let id = item?.extension.id; + + if (!id) { + id = await vscode.window.showInputBox({ + prompt: vscode.l10n.t('Enter the ID of the extension to upgrade'), + placeHolder: vscode.l10n.t('Extension ID') + }); + } + + if (!id) { + return; + } + + const args = composeArgs( + withArg('extension', 'install'), + withQuotedArg(id), + withArg('--force') + )(); + + await runExtensionCommand(context, args, vscode.l10n.t('Upgrading extension...'), TelemetryId.ExtensionUpgradeCli); +} diff --git a/ext/vscode/src/commands/monitor.ts b/ext/vscode/src/commands/monitor.ts index a527e8dae84..b204e4c9090 100644 --- a/ext/vscode/src/commands/monitor.ts +++ b/ext/vscode/src/commands/monitor.ts @@ -6,7 +6,7 @@ import { composeArgs, withArg } from '@microsoft/vscode-processutils'; import * as vscode from 'vscode'; import { createAzureDevCli } from '../utils/azureDevCli'; import { execAsync } from '../utils/execAsync'; -import { isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; +import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliApplication } from '../views/workspace/AzureDevCliApplication'; import { getWorkingFolder } from './cmdUtil'; @@ -27,7 +27,14 @@ const MonitorChoices: IAzureQuickPickItem[] = [ ]; export async function monitor(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; + let selectedFile: vscode.Uri | undefined; + if (isTreeViewModel(selectedItem)) { + selectedFile = selectedItem.unwrap().context.configurationFile; + } else if (isAzureDevCliModel(selectedItem)) { + selectedFile = selectedItem.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } const workingFolder = await getWorkingFolder(context, selectedFile); const monitorChoices = await context.ui.showQuickPick(MonitorChoices, { diff --git a/ext/vscode/src/commands/packageCli.ts b/ext/vscode/src/commands/packageCli.ts index aec44d0aef7..d485595bd44 100644 --- a/ext/vscode/src/commands/packageCli.ts +++ b/ext/vscode/src/commands/packageCli.ts @@ -7,15 +7,25 @@ import * as vscode from 'vscode'; import { TelemetryId } from '../telemetry/telemetryId'; import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; -import { isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; +import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliModel } from '../views/workspace/AzureDevCliModel'; import { AzureDevCliService } from '../views/workspace/AzureDevCliService'; import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; // `package` is a reserved identifier so `packageCli` had to be used instead export async function packageCli(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { - const selectedModel = isTreeViewModel(selectedItem) ? selectedItem.unwrap() : undefined; - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; + let selectedModel: AzureDevCliModel | undefined; + let selectedFile: vscode.Uri | undefined; + + if (isTreeViewModel(selectedItem)) { + selectedModel = selectedItem.unwrap(); + selectedFile = selectedModel.context.configurationFile; + } else if (isAzureDevCliModel(selectedItem)) { + selectedModel = selectedItem; + selectedFile = selectedModel.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/commands/pipeline.ts b/ext/vscode/src/commands/pipeline.ts index f6344911eb7..6cf6b92f1d6 100644 --- a/ext/vscode/src/commands/pipeline.ts +++ b/ext/vscode/src/commands/pipeline.ts @@ -8,7 +8,7 @@ import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; import { TelemetryId } from '../telemetry/telemetryId'; import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; -import { isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; +import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliApplication } from '../views/workspace/AzureDevCliApplication'; /** @@ -19,7 +19,14 @@ export type PipelineConfigCommandArguments = [ vscode.Uri | undefined, boolean? export async function pipelineConfig(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel, fromAgent: boolean = false): Promise { context.telemetry.properties.fromAgent = fromAgent.toString(); - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; + let selectedFile: vscode.Uri | undefined; + if (isTreeViewModel(selectedItem)) { + selectedFile = selectedItem.unwrap().context.configurationFile; + } else if (isAzureDevCliModel(selectedItem)) { + selectedFile = selectedItem.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/commands/provision.ts b/ext/vscode/src/commands/provision.ts index ad3e02d42de..6a42f1462c4 100644 --- a/ext/vscode/src/commands/provision.ts +++ b/ext/vscode/src/commands/provision.ts @@ -7,12 +7,19 @@ import * as vscode from 'vscode'; import { TelemetryId } from '../telemetry/telemetryId'; import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; -import { isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; +import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliApplication } from '../views/workspace/AzureDevCliApplication'; import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; export async function provision(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; + let selectedFile: vscode.Uri | undefined; + if (isTreeViewModel(selectedItem)) { + selectedFile = selectedItem.unwrap().context.configurationFile; + } else if (isAzureDevCliModel(selectedItem)) { + selectedFile = selectedItem.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/commands/registerCommands.ts b/ext/vscode/src/commands/registerCommands.ts index 5f4b66f1095..ec0b62207b4 100644 --- a/ext/vscode/src/commands/registerCommands.ts +++ b/ext/vscode/src/commands/registerCommands.ts @@ -17,8 +17,11 @@ import { pipelineConfig } from './pipeline'; import { installCli } from './installCli'; import { loginCli } from './loginCli'; import { getDotEnvFilePath } from './getDotEnvFilePath'; -import { revealAzureResource, revealAzureResourceGroup } from './azureWorkspace/reveal'; +import { revealAzureResource, revealAzureResourceGroup, showInAzurePortal } from './azureWorkspace/reveal'; import { disableDevCenterMode, enableDevCenterMode } from './devCenterMode'; +import { installExtension, uninstallExtension, upgradeExtension } from './extensions'; +import { addService } from './addService'; +import { initFromCode, initMinimal, initFromTemplate, searchTemplates, openGallery, openReadme, openGitHubRepo } from './templateTools'; export function registerCommands(): void { registerActivityCommand('azure-dev.commands.cli.init', init); @@ -39,18 +42,35 @@ export function registerCommands(): void { registerActivityCommand('azure-dev.commands.cli.pipeline-config', pipelineConfig); registerActivityCommand('azure-dev.commands.cli.install', installCli); registerActivityCommand('azure-dev.commands.cli.login', loginCli); + registerActivityCommand('azure-dev.commands.cli.extension-install', installExtension); + registerActivityCommand('azure-dev.commands.cli.extension-uninstall', uninstallExtension); + registerActivityCommand('azure-dev.commands.cli.extension-upgrade', upgradeExtension); + registerActivityCommand('azure-dev.commands.addService', addService); registerActivityCommand('azure-dev.commands.azureWorkspace.revealAzureResource', revealAzureResource); registerActivityCommand('azure-dev.commands.azureWorkspace.revealAzureResourceGroup', revealAzureResourceGroup); + registerActivityCommand('azure-dev.commands.azureWorkspace.showInAzurePortal', showInAzurePortal); registerActivityCommand('azure-dev.commands.enableDevCenterMode', enableDevCenterMode); registerActivityCommand('azure-dev.commands.disableDevCenterMode', disableDevCenterMode); + registerActivityCommand('azure-dev.views.templateTools.initFromCode', initFromCode); + registerActivityCommand('azure-dev.views.templateTools.initMinimal', initMinimal); + registerActivityCommand('azure-dev.views.templateTools.initFromTemplate', initFromTemplate); + registerActivityCommand('azure-dev.views.templateTools.initFromTemplateInline', initFromTemplate); + registerActivityCommand('azure-dev.views.templateTools.search', searchTemplates); + registerActivityCommand('azure-dev.views.templateTools.openGallery', openGallery); + registerActivityCommand('azure-dev.views.templateTools.openReadme', openReadme); + registerActivityCommand('azure-dev.views.templateTools.openGitHub', openGitHubRepo); + // getDotEnvFilePath() is a utility command that does not deserve "user activity" designation. registerCommandAzUI('azure-dev.commands.getDotEnvFilePath', getDotEnvFilePath); } -function registerActivityCommand(commandId: string, callback: CommandCallback, debounce?: number, telemetryId?:string): void { +// registerActivityCommand wraps a command callback with activity recording. +// The command ID is automatically used as the telemetry event name by registerCommandAzUI. +// For CLI task executions, telemetry is separately tracked via executeAsTask() with TelemetryId enum values. +function registerActivityCommand(commandId: string, callback: CommandCallback, debounce?: number): void { registerCommandAzUI( commandId, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -58,7 +78,6 @@ function registerActivityCommand(commandId: string, callback: CommandCallback, d void ext.activitySvc.recordActivity(); return callback(context, ...args); }, - debounce, - telemetryId + debounce ); } diff --git a/ext/vscode/src/commands/restore.ts b/ext/vscode/src/commands/restore.ts index 0fb73abb6fa..6b6ea733fe6 100644 --- a/ext/vscode/src/commands/restore.ts +++ b/ext/vscode/src/commands/restore.ts @@ -7,14 +7,24 @@ import * as vscode from 'vscode'; import { TelemetryId } from '../telemetry/telemetryId'; import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; -import { isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; +import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliModel } from '../views/workspace/AzureDevCliModel'; import { AzureDevCliService } from '../views/workspace/AzureDevCliService'; import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; export async function restore(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { - const selectedModel = isTreeViewModel(selectedItem) ? selectedItem.unwrap() : undefined; - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; + let selectedModel: AzureDevCliModel | undefined; + let selectedFile: vscode.Uri | undefined; + + if (isTreeViewModel(selectedItem)) { + selectedModel = selectedItem.unwrap(); + selectedFile = selectedModel.context.configurationFile; + } else if (isAzureDevCliModel(selectedItem)) { + selectedModel = selectedItem; + selectedFile = selectedModel.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/commands/templateTools.ts b/ext/vscode/src/commands/templateTools.ts new file mode 100644 index 00000000000..ca972a78b79 --- /dev/null +++ b/ext/vscode/src/commands/templateTools.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { Template, AzureDevTemplateProvider } from '../services/AzureDevTemplateProvider'; +import { quickPickWorkspaceFolder } from '../utils/quickPickWorkspaceFolder'; +import { init } from './init'; + +const templateProvider = new AzureDevTemplateProvider(); + +export async function initFromCode(context: IActionContext): Promise { + const workspaceFolder = await quickPickWorkspaceFolder(context, vscode.l10n.t('Select a workspace folder to initialize')); + + await init(context, workspaceFolder.uri, undefined, undefined); +} + +export async function initMinimal(context: IActionContext): Promise { + const workspaceFolder = await quickPickWorkspaceFolder(context, vscode.l10n.t('Select a workspace folder to create minimal project')); + + // Call azd init with minimal flag + await vscode.commands.executeCommand('azure-dev.commands.cli.init', workspaceFolder.uri, undefined, { minimal: true }); +} + +export async function initFromTemplate(context: IActionContext, template?: Template): Promise { + if (!template) { + vscode.window.showErrorMessage(vscode.l10n.t('No template selected')); + return; + } + + const workspaceFolder = await quickPickWorkspaceFolder(context, vscode.l10n.t('Select a workspace folder to initialize with template')); + + const templatePath = templateProvider.extractTemplatePath(template.source); + + // Call init with template path + await init(context, workspaceFolder.uri, undefined, { templateUrl: templatePath }); +} + +export async function searchTemplates(context: IActionContext): Promise { + const quickPick = vscode.window.createQuickPick(); + quickPick.placeholder = vscode.l10n.t('Search templates (e.g., "react", "python ai", "cosmos")'); + quickPick.matchOnDescription = true; + quickPick.matchOnDetail = true; + + // Show loading + quickPick.busy = true; + quickPick.show(); + + // Load all templates + const templates = await templateProvider.getTemplates(); + quickPick.busy = false; + + quickPick.items = templates.map(t => ({ + label: t.title, + description: t.tags?.slice(0, 3).join(', '), + detail: t.description, + template: t + })); + + quickPick.onDidChangeValue(async (value) => { + if (value.length >= 2) { + quickPick.busy = true; + const results = await templateProvider.searchTemplates(value); + quickPick.items = results.map(t => ({ + label: t.title, + description: t.tags?.slice(0, 3).join(', '), + detail: t.description, + template: t + })); + quickPick.busy = false; + } else if (value.length === 0) { + quickPick.items = templates.map(t => ({ + label: t.title, + description: t.tags?.slice(0, 3).join(', '), + detail: t.description, + template: t + })); + } + }); + + quickPick.onDidAccept(async () => { + const selected = quickPick.selectedItems[0]; + if (selected?.template) { + quickPick.hide(); + await initFromTemplate(context, selected.template); + } + }); + + quickPick.onDidHide(() => { + quickPick.dispose(); + }); +} + +export async function openGallery(context: IActionContext): Promise { + await vscode.env.openExternal(vscode.Uri.parse('https://aka.ms/awesome-azd')); +} + +export async function openReadme(context: IActionContext, template?: Template): Promise { + if (!template) { + vscode.window.showErrorMessage(vscode.l10n.t('No template selected')); + return; + } + + // Construct README URL from template source + const readmeUrl = template.source.endsWith('/') + ? `${template.source}blob/main/README.md` + : `${template.source}/blob/main/README.md`; + + await vscode.env.openExternal(vscode.Uri.parse(readmeUrl)); +} + +export async function openGitHubRepo(context: IActionContext, template?: Template): Promise { + if (!template) { + vscode.window.showErrorMessage(vscode.l10n.t('No template selected')); + return; + } + + await vscode.env.openExternal(vscode.Uri.parse(template.source)); +} diff --git a/ext/vscode/src/commands/up.ts b/ext/vscode/src/commands/up.ts index b5ece32502c..dbf72bc5112 100644 --- a/ext/vscode/src/commands/up.ts +++ b/ext/vscode/src/commands/up.ts @@ -7,7 +7,7 @@ import * as vscode from 'vscode'; import { TelemetryId } from '../telemetry/telemetryId'; import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; -import { isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; +import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliApplication } from '../views/workspace/AzureDevCliApplication'; import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; @@ -19,7 +19,14 @@ export type UpCommandArguments = [ vscode.Uri | TreeViewModel | undefined, boole export async function up(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel, fromAgent: boolean = false): Promise { context.telemetry.properties.fromAgent = fromAgent.toString(); - const selectedFile = isTreeViewModel(selectedItem) ? selectedItem.unwrap().context.configurationFile : selectedItem; + let selectedFile: vscode.Uri | undefined; + if (isTreeViewModel(selectedItem)) { + selectedFile = selectedItem.unwrap().context.configurationFile; + } else if (isAzureDevCliModel(selectedItem)) { + selectedFile = selectedItem.context.configurationFile; + } else { + selectedFile = selectedItem as vscode.Uri; + } const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/extension.ts b/ext/vscode/src/extension.ts index e162c21aee0..f8a950f32a4 100644 --- a/ext/vscode/src/extension.ts +++ b/ext/vscode/src/extension.ts @@ -13,6 +13,7 @@ import { LoginStatus, getAzdLoginStatus, scheduleAzdSignInCheck, scheduleAzdVers import { activeSurveys } from './telemetry/activeSurveys'; import { scheduleRegisterWorkspaceComponents } from './views/workspace/scheduleRegisterWorkspaceComponents'; import { registerLanguageFeatures } from './language/languageFeatures'; +import { registerViews } from './views/registerViews'; type LoadStats = { // Both are the values returned by Date.now()==milliseconds since Unix epoch. @@ -54,6 +55,7 @@ export async function activateInternal(vscodeCtx: vscode.ExtensionContext, loadS registerCommands(); registerDisposable(vscode.tasks.registerTaskProvider('dotenv', new DotEnvTaskProvider())); registerLanguageFeatures(); + registerViews(vscodeCtx); scheduleRegisterWorkspaceComponents(vscodeCtx); scheduleSurveys(vscodeCtx.globalState, activeSurveys); scheduleAzdVersionCheck(); // Temporary diff --git a/ext/vscode/src/language/AzureYamlCodeActionProvider.ts b/ext/vscode/src/language/AzureYamlCodeActionProvider.ts new file mode 100644 index 00000000000..3d5acf90500 --- /dev/null +++ b/ext/vscode/src/language/AzureYamlCodeActionProvider.ts @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { AzExtFsExtra } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import * as yaml from 'yaml'; +import { getContainingFolderUri } from './azureYamlUtils'; + +/** + * Provides code actions (quick fixes) for azure.yaml files + */ +export class AzureYamlCodeActionProvider implements vscode.CodeActionProvider { + public static readonly providedCodeActionKinds = [ + vscode.CodeActionKind.QuickFix + ]; + + public async provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection, + context: vscode.CodeActionContext, + token: vscode.CancellationToken + ): Promise { + const actions: vscode.CodeAction[] = []; + + for (const diagnostic of context.diagnostics) { + // Quick fix for missing project paths + if (diagnostic.message.includes('project path must be an existing')) { + actions.push(this.createCreateFolderAction(document, diagnostic)); + actions.push(this.createBrowseForFolderAction(document, diagnostic)); + } + + // Quick fix for missing language property + if (diagnostic.message.includes('language') && diagnostic.message.includes('missing')) { + actions.push(...this.createAddLanguageActions(document, diagnostic)); + } + + // Quick fix for missing host property + if (diagnostic.message.includes('host') && diagnostic.message.includes('missing')) { + actions.push(...this.createAddHostActions(document, diagnostic)); + } + } + + // Add general code actions + actions.push(...await this.provideGeneralActions(document, range)); + + return actions; + } + + private createCreateFolderAction(document: vscode.TextDocument, diagnostic: vscode.Diagnostic): vscode.CodeAction { + const action = new vscode.CodeAction('Create folder', vscode.CodeActionKind.QuickFix); + action.diagnostics = [diagnostic]; + action.isPreferred = true; + + const projectPath = this.extractProjectPath(document, diagnostic.range); + if (projectPath) { + action.command = { + title: 'Create folder', + command: 'azure-dev.codeAction.createProjectFolder', + arguments: [document.uri, projectPath] + }; + } + + return action; + } + + private createBrowseForFolderAction(document: vscode.TextDocument, diagnostic: vscode.Diagnostic): vscode.CodeAction { + const action = new vscode.CodeAction('Browse for existing folder...', vscode.CodeActionKind.QuickFix); + action.diagnostics = [diagnostic]; + + action.command = { + title: 'Browse for folder', + command: 'azure-dev.codeAction.browseForProjectFolder', + arguments: [document.uri, diagnostic.range] + }; + + return action; + } + + private createAddLanguageActions(document: vscode.TextDocument, diagnostic: vscode.Diagnostic): vscode.CodeAction[] { + const languages = ['python', 'js', 'ts', 'csharp', 'java', 'go']; + return languages.map(lang => { + const action = new vscode.CodeAction(`Add language: ${lang}`, vscode.CodeActionKind.QuickFix); + action.diagnostics = [diagnostic]; + action.edit = new vscode.WorkspaceEdit(); + + // Find the line to insert the language property + const insertPosition = new vscode.Position(diagnostic.range.start.line + 1, diagnostic.range.start.character); + action.edit.insert(document.uri, insertPosition, ` language: ${lang}\n`); + + return action; + }); + } + + private createAddHostActions(document: vscode.TextDocument, diagnostic: vscode.Diagnostic): vscode.CodeAction[] { + const hosts = [ + { value: 'containerapp', label: 'Container Apps' }, + { value: 'appservice', label: 'App Service' }, + { value: 'function', label: 'Functions' } + ]; + + return hosts.map(host => { + const action = new vscode.CodeAction(`Add host: ${host.label}`, vscode.CodeActionKind.QuickFix); + action.diagnostics = [diagnostic]; + action.edit = new vscode.WorkspaceEdit(); + + const insertPosition = new vscode.Position(diagnostic.range.start.line + 1, diagnostic.range.start.character); + action.edit.insert(document.uri, insertPosition, ` host: ${host.value}\n`); + + return action; + }); + } + + private async provideGeneralActions(document: vscode.TextDocument, range: vscode.Range): Promise { + const actions: vscode.CodeAction[] = []; + + // Add "Add new service" refactoring action + const addServiceAction = new vscode.CodeAction('Add new service...', vscode.CodeActionKind.Refactor); + addServiceAction.command = { + title: 'Add new service', + command: 'azure-dev.codeAction.addService', + arguments: [document.uri] + }; + actions.push(addServiceAction); + + return actions; + } + + private extractProjectPath(document: vscode.TextDocument, range: vscode.Range): string | undefined { + try { + const line = document.lineAt(range.start.line); + const match = line.text.match(/project:\s*(.+)/); + return match ? match[1].trim().replace(/['"]/g, '') : undefined; + } catch { + return undefined; + } + } +} + +/** + * Code action command handlers + */ +export async function registerCodeActionCommands(context: vscode.ExtensionContext): Promise { + context.subscriptions.push( + vscode.commands.registerCommand('azure-dev.codeAction.createProjectFolder', async (documentUri: vscode.Uri, projectPath: string) => { + try { + const folderUri = vscode.Uri.joinPath(getContainingFolderUri(documentUri), projectPath); + await AzExtFsExtra.ensureDir(folderUri.fsPath); + void vscode.window.showInformationMessage(`Created folder: ${projectPath}`); + } catch (error) { + void vscode.window.showErrorMessage(`Failed to create folder: ${error instanceof Error ? error.message : String(error)}`); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('azure-dev.codeAction.browseForProjectFolder', async (documentUri: vscode.Uri, range: vscode.Range) => { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); + const selected = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: workspaceFolder?.uri, + openLabel: 'Select Project Folder' + }); + + if (selected && selected[0]) { + const relativePath = vscode.workspace.asRelativePath(selected[0], false); + const document = await vscode.workspace.openTextDocument(documentUri); + const edit = new vscode.WorkspaceEdit(); + + const line = document.lineAt(range.start.line); + const match = line.text.match(/project:\s*.+/); + if (match) { + const replaceRange = new vscode.Range( + range.start.line, + line.text.indexOf(match[0]) + 'project: '.length, + range.start.line, + line.text.length + ); + edit.replace(documentUri, replaceRange, `./${relativePath}`); + await vscode.workspace.applyEdit(edit); + } + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('azure-dev.codeAction.addService', async (documentUri: vscode.Uri) => { + const serviceName = await vscode.window.showInputBox({ + prompt: 'Enter service name', + placeHolder: 'api', + validateInput: (value) => { + if (!value || !/^[a-zA-Z0-9-_]+$/.test(value)) { + return 'Service name must contain only letters, numbers, hyphens, and underscores'; + } + return undefined; + } + }); + + if (!serviceName) { + return; + } + + const language = await vscode.window.showQuickPick( + ['python', 'js', 'ts', 'csharp', 'java', 'go'], + { placeHolder: 'Select programming language' } + ); + + if (!language) { + return; + } + + const host = await vscode.window.showQuickPick( + [ + { label: 'containerapp', description: 'Azure Container Apps' }, + { label: 'appservice', description: 'Azure App Service' }, + { label: 'function', description: 'Azure Functions' } + ], + { placeHolder: 'Select Azure host' } + ); + + if (!host) { + return; + } + + const document = await vscode.workspace.openTextDocument(documentUri); + const text = document.getText(); + const doc = yaml.parseDocument(text); + + const services = doc.get('services') as yaml.YAMLMap; + if (!services) { + void vscode.window.showErrorMessage('No services section found in azure.yaml'); + return; + } + + const serviceSnippet = `\n ${serviceName}:\n project: ./${serviceName}\n language: ${language}\n host: ${host.label}`; + + // Find the end of the services section + if (doc.contents && yaml.isMap(doc.contents)) { + const servicesNode = doc.contents.items.find((item) => yaml.isScalar(item.key) && item.key.value === 'services'); + if (servicesNode && servicesNode.value && 'range' in servicesNode.value && servicesNode.value.range) { + const insertPosition = document.positionAt(servicesNode.value.range[1]); + const edit = new vscode.WorkspaceEdit(); + edit.insert(documentUri, insertPosition, serviceSnippet); + await vscode.workspace.applyEdit(edit); + } + } + }) + ); +} diff --git a/ext/vscode/src/language/AzureYamlCompletionProvider.ts b/ext/vscode/src/language/AzureYamlCompletionProvider.ts new file mode 100644 index 00000000000..d5068cc96c8 --- /dev/null +++ b/ext/vscode/src/language/AzureYamlCompletionProvider.ts @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as vscode from 'vscode'; +import * as yaml from 'yaml'; + +/** + * Provides auto-completion for azure.yaml files + */ +export class AzureYamlCompletionProvider implements vscode.CompletionItemProvider { + // Common Azure service host types + private readonly hostTypes = [ + { label: 'containerapp', detail: 'Azure Container Apps', documentation: 'Deploy containerized applications' }, + { label: 'appservice', detail: 'Azure App Service', documentation: 'Deploy web applications' }, + { label: 'function', detail: 'Azure Functions', documentation: 'Deploy serverless functions' }, + { label: 'aks', detail: 'Azure Kubernetes Service', documentation: 'Deploy to Kubernetes cluster' }, + { label: 'staticwebapp', detail: 'Azure Static Web Apps', documentation: 'Deploy static web applications' }, + ]; + + // Common hook types + private readonly hookTypes = [ + { label: 'prerestore', documentation: 'Run before restoring dependencies' }, + { label: 'postrestore', documentation: 'Run after restoring dependencies' }, + { label: 'preprovision', documentation: 'Run before provisioning infrastructure' }, + { label: 'postprovision', documentation: 'Run after provisioning infrastructure' }, + { label: 'predeploy', documentation: 'Run before deploying application' }, + { label: 'postdeploy', documentation: 'Run after deploying application' }, + ]; + + // Common service properties + private readonly serviceProperties = [ + { label: 'project', detail: 'string', documentation: 'Relative path to the service project directory' }, + { label: 'language', detail: 'string', documentation: 'Programming language (e.g., js, ts, python, csharp, java)' }, + { label: 'host', detail: 'string', documentation: 'Azure hosting platform for the service' }, + { label: 'hooks', detail: 'object', documentation: 'Lifecycle hooks for the service' }, + { label: 'docker', detail: 'object', documentation: 'Docker configuration for containerized services' }, + { label: 'resourceName', detail: 'string', documentation: 'Name override for the Azure resource' }, + ]; + + // Top-level properties + private readonly topLevelProperties = [ + { label: 'name', detail: 'string', documentation: 'Application name' }, + { label: 'metadata', detail: 'object', documentation: 'Application metadata' }, + { label: 'services', detail: 'object', documentation: 'Service definitions' }, + { label: 'pipeline', detail: 'object', documentation: 'CI/CD pipeline configuration' }, + { label: 'hooks', detail: 'object', documentation: 'Application-level lifecycle hooks' }, + ]; + + public provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + context: vscode.CompletionContext + ): vscode.ProviderResult { + const linePrefix = document.lineAt(position).text.substring(0, position.character); + const yamlPath = this.getYamlPath(document, position); + + // Complete host types + if (this.shouldCompleteHostType(linePrefix, yamlPath)) { + return this.hostTypes.map(host => { + const item = new vscode.CompletionItem(host.label, vscode.CompletionItemKind.Value); + item.detail = host.detail; + item.documentation = new vscode.MarkdownString(host.documentation); + return item; + }); + } + + // Complete hook types + if (this.shouldCompleteHookType(yamlPath)) { + return this.hookTypes.map(hook => { + const item = new vscode.CompletionItem(hook.label, vscode.CompletionItemKind.Property); + item.documentation = new vscode.MarkdownString(hook.documentation); + item.insertText = new vscode.SnippetString(`${hook.label}:\n run: \${1:command}\n shell: \${2|sh,bash,pwsh|}\n continueOnError: \${3|false,true|}`); + return item; + }); + } + + // Complete service properties + if (this.shouldCompleteServiceProperty(yamlPath)) { + return this.serviceProperties.map(prop => { + const item = new vscode.CompletionItem(prop.label, vscode.CompletionItemKind.Property); + item.detail = prop.detail; + item.documentation = new vscode.MarkdownString(prop.documentation); + + if (prop.label === 'host') { + item.insertText = new vscode.SnippetString('host: ${1|containerapp,appservice,function,aks,staticwebapp|}'); + } else if (prop.label === 'project') { + item.insertText = new vscode.SnippetString('project: ./${1:path}'); + } else if (prop.label === 'language') { + item.insertText = new vscode.SnippetString('language: ${1|js,ts,python,csharp,java,go|}'); + } + + return item; + }); + } + + // Complete top-level properties + if (this.shouldCompleteTopLevelProperty(yamlPath)) { + return this.topLevelProperties.map(prop => { + const item = new vscode.CompletionItem(prop.label, vscode.CompletionItemKind.Property); + item.detail = prop.detail; + item.documentation = new vscode.MarkdownString(prop.documentation); + + if (prop.label === 'services') { + item.insertText = new vscode.SnippetString('services:\n ${1:serviceName}:\n project: ./${2:path}\n language: ${3|js,ts,python,csharp,java|}\n host: ${4|containerapp,appservice,function|}'); + } + + return item; + }); + } + + return []; + } + + private getYamlPath(document: vscode.TextDocument, position: vscode.Position): string[] { + const text = document.getText(new vscode.Range(new vscode.Position(0, 0), position)); + try { + // Parse document to validate YAML structure + yaml.parseDocument(text); + const path: string[] = []; + + // This is a simplified path detection - in production, you'd want more robust parsing + const lines = text.split('\n'); + let currentIndent = 0; + + for (let i = position.line; i >= 0; i--) { + const line = lines[i]; + const indent = line.search(/\S/); + + if (indent < 0) { + continue; + } + + if (indent < currentIndent || currentIndent === 0) { + const match = line.match(/^\s*(\w+):/); + if (match) { + path.unshift(match[1]); + currentIndent = indent; + } + } + } + + return path; + } catch { + return []; + } + } + + private shouldCompleteHostType(linePrefix: string, yamlPath: string[]): boolean { + return linePrefix.trim().startsWith('host:') || + (yamlPath.includes('services') && linePrefix.includes('host')); + } + + private shouldCompleteHookType(yamlPath: string[]): boolean { + return yamlPath.includes('hooks'); + } + + private shouldCompleteServiceProperty(yamlPath: string[]): boolean { + return yamlPath.includes('services') && yamlPath.length >= 2 && !yamlPath.includes('hooks'); + } + + private shouldCompleteTopLevelProperty(yamlPath: string[]): boolean { + return yamlPath.length === 0 || (yamlPath.length === 1 && yamlPath[0] === 'name'); + } +} diff --git a/ext/vscode/src/language/AzureYamlDiagnosticProvider.ts b/ext/vscode/src/language/AzureYamlDiagnosticProvider.ts index 3bd706372b4..8ac55c2a069 100644 --- a/ext/vscode/src/language/AzureYamlDiagnosticProvider.ts +++ b/ext/vscode/src/language/AzureYamlDiagnosticProvider.ts @@ -3,6 +3,7 @@ import { AzExtFsExtra, IActionContext, callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; +import * as yaml from 'yaml'; import { documentDebounce } from './documentDebounce'; import { getAzureYamlProjectInformation } from './azureYamlUtils'; import { TelemetryId } from '../telemetry/telemetryId'; @@ -54,6 +55,9 @@ export class AzureYamlDiagnosticProvider extends vscode.Disposable { results.push(diagnostic); } + + // Additional validation checks + results.push(...this.validateYamlStructure(document)); } catch { // Best effort--the YAML extension will show parsing errors for us if it is present } @@ -63,6 +67,92 @@ export class AzureYamlDiagnosticProvider extends vscode.Disposable { }); } + private validateYamlStructure(document: vscode.TextDocument): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + const text = document.getText(); + + try { + const doc = yaml.parseDocument(text); + + if (!doc || doc.errors.length > 0) { + return diagnostics; + } + + const content = doc.toJSON(); + + // Validate required name property + if (!content.name) { + diagnostics.push(new vscode.Diagnostic( + new vscode.Range(0, 0, 0, 0), + vscode.l10n.t('Missing required "name" property. Add a name for your application.'), + vscode.DiagnosticSeverity.Warning + )); + } + + // Validate services structure + if (content.services) { + for (const [serviceName, service] of Object.entries(content.services as Record)) { + const serviceLineNumber = this.findLineNumber(text, serviceName); + + // Warn about missing language + if (!service.language) { + diagnostics.push(new vscode.Diagnostic( + new vscode.Range(serviceLineNumber, 0, serviceLineNumber, 100), + vscode.l10n.t('Service "{0}" is missing "language" property. This helps azd understand your project.', serviceName), + vscode.DiagnosticSeverity.Information + )); + } + + // Warn about missing host + if (!service.host) { + diagnostics.push(new vscode.Diagnostic( + new vscode.Range(serviceLineNumber, 0, serviceLineNumber, 100), + vscode.l10n.t('Service "{0}" is missing "host" property. Specify the Azure platform for deployment.', serviceName), + vscode.DiagnosticSeverity.Information + )); + } + + // Validate host value + if (service.host) { + const validHosts = ['containerapp', 'appservice', 'function', 'aks', 'staticwebapp']; + if (!validHosts.includes(service.host)) { + const hostLineNumber = this.findLineNumber(text, 'host:', serviceLineNumber); + diagnostics.push(new vscode.Diagnostic( + new vscode.Range(hostLineNumber, 0, hostLineNumber, 100), + vscode.l10n.t('Invalid host type "{0}". Valid options: {1}', service.host, validHosts.join(', ')), + vscode.DiagnosticSeverity.Warning + )); + } + } + + // Validate project path format + if (service.project && !service.project.startsWith('./')) { + const projectLineNumber = this.findLineNumber(text, 'project:', serviceLineNumber); + diagnostics.push(new vscode.Diagnostic( + new vscode.Range(projectLineNumber, 0, projectLineNumber, 100), + vscode.l10n.t('Project paths should start with "./" for clarity.'), + vscode.DiagnosticSeverity.Information + )); + } + } + } + } catch { + // Ignore parsing errors - YAML extension handles those + } + + return diagnostics; + } + + private findLineNumber(text: string, searchString: string, startLine: number = 0): number { + const lines = text.split('\n'); + for (let i = startLine; i < lines.length; i++) { + if (lines[i].includes(searchString)) { + return i; + } + } + return startLine; + } + private async updateDiagnosticsFor(document: vscode.TextDocument, delay: boolean = true): Promise { if (!vscode.languages.match(this.selector, document)) { return; diff --git a/ext/vscode/src/language/AzureYamlHoverProvider.ts b/ext/vscode/src/language/AzureYamlHoverProvider.ts new file mode 100644 index 00000000000..437d6b8e654 --- /dev/null +++ b/ext/vscode/src/language/AzureYamlHoverProvider.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as vscode from 'vscode'; + +/** + * Provides hover documentation for azure.yaml files + */ +export class AzureYamlHoverProvider implements vscode.HoverProvider { + private readonly documentation: Map = new Map([ + ['name', { + title: 'Application Name', + description: 'The name of your Azure application. This is used for display and identification purposes.', + example: 'name: my-awesome-app' + }], + ['services', { + title: 'Services', + description: 'Defines the services that make up your application. Each service represents a deployable component.', + example: 'services:\n api:\n project: ./src/api\n language: python\n host: containerapp' + }], + ['project', { + title: 'Project Path', + description: 'Relative path to the service project directory from the azure.yaml file. Should point to the folder containing your application code.', + example: 'project: ./src/api' + }], + ['language', { + title: 'Programming Language', + description: 'The programming language used by the service. Supported values: js, ts, python, csharp, java, go, php.', + example: 'language: python' + }], + ['host', { + title: 'Azure Host', + description: 'The Azure platform where the service will be deployed.\n\n**Options:**\n- `containerapp` - Azure Container Apps\n- `appservice` - Azure App Service\n- `function` - Azure Functions\n- `aks` - Azure Kubernetes Service\n- `staticwebapp` - Azure Static Web Apps', + example: 'host: containerapp' + }], + ['hooks', { + title: 'Lifecycle Hooks', + description: 'Commands to run at specific points in the deployment lifecycle.\n\n**Available hooks:**\n- `prerestore` - Before restoring dependencies\n- `postrestore` - After restoring dependencies\n- `preprovision` - Before provisioning infrastructure\n- `postprovision` - After provisioning infrastructure\n- `predeploy` - Before deploying application\n- `postdeploy` - After deploying application', + example: 'hooks:\n postdeploy:\n run: npm run migrate\n shell: sh\n continueOnError: false' + }], + ['docker', { + title: 'Docker Configuration', + description: 'Docker-specific settings for containerized services.', + example: 'docker:\n path: ./Dockerfile\n context: .' + }], + ['resourceName', { + title: 'Resource Name Override', + description: 'Override the default Azure resource name. By default, azd generates resource names based on environment and service names.', + example: 'resourceName: my-custom-resource-name' + }], + ['pipeline', { + title: 'CI/CD Pipeline', + description: 'Configuration for continuous integration and deployment pipelines.', + example: 'pipeline:\n provider: github' + }], + ['metadata', { + title: 'Metadata', + description: 'Additional metadata about the application, such as template information.', + example: 'metadata:\n template: todo-python-mongo' + }] + ]); + + public provideHover( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken + ): vscode.ProviderResult { + const range = document.getWordRangeAtPosition(position); + if (!range) { + return null; + } + + const word = document.getText(range); + const doc = this.documentation.get(word); + + if (!doc) { + return null; + } + + const markdown = new vscode.MarkdownString(); + markdown.appendMarkdown(`### ${doc.title}\n\n`); + markdown.appendMarkdown(doc.description); + + if (doc.example) { + markdown.appendMarkdown('\n\n**Example:**\n```yaml\n' + doc.example + '\n```'); + } + + markdown.appendMarkdown('\n\n[View Documentation](https://learn.microsoft.com/azure/developer/azure-developer-cli/azd-schema)'); + + return new vscode.Hover(markdown, range); + } +} diff --git a/ext/vscode/src/language/languageFeatures.ts b/ext/vscode/src/language/languageFeatures.ts index a0cf4298798..1c0c70b8038 100644 --- a/ext/vscode/src/language/languageFeatures.ts +++ b/ext/vscode/src/language/languageFeatures.ts @@ -6,6 +6,9 @@ import ext from '../ext'; import { AzureYamlDiagnosticProvider } from './AzureYamlDiagnosticProvider'; import { AzureYamlProjectRenameProvider } from './AzureYamlProjectRenameProvider'; import { AzureYamlDocumentDropEditProvider } from './AzureYamlDocumentDropEditProvider'; +import { AzureYamlCompletionProvider } from './AzureYamlCompletionProvider'; +import { AzureYamlHoverProvider } from './AzureYamlHoverProvider'; +import { AzureYamlCodeActionProvider, registerCodeActionCommands } from './AzureYamlCodeActionProvider'; export const AzureYamlSelector: vscode.DocumentSelector = { language: 'yaml', scheme: 'file', pattern: '**/azure.{yml,yaml}' }; @@ -21,4 +24,35 @@ export function registerLanguageFeatures(): void { ext.context.subscriptions.push( vscode.languages.registerDocumentDropEditProvider(AzureYamlSelector, new AzureYamlDocumentDropEditProvider()) ); + + // Register completion provider + ext.context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + AzureYamlSelector, + new AzureYamlCompletionProvider(), + ':', ' ', '\n' + ) + ); + + // Register hover provider + ext.context.subscriptions.push( + vscode.languages.registerHoverProvider( + AzureYamlSelector, + new AzureYamlHoverProvider() + ) + ); + + // Register code action provider + ext.context.subscriptions.push( + vscode.languages.registerCodeActionsProvider( + AzureYamlSelector, + new AzureYamlCodeActionProvider(), + { + providedCodeActionKinds: AzureYamlCodeActionProvider.providedCodeActionKinds + } + ) + ); + + // Register code action commands + void registerCodeActionCommands(ext.context); } diff --git a/ext/vscode/src/services/AzureDevEnvValuesProvider.ts b/ext/vscode/src/services/AzureDevEnvValuesProvider.ts new file mode 100644 index 00000000000..301c55859ae --- /dev/null +++ b/ext/vscode/src/services/AzureDevEnvValuesProvider.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IActionContext } from '@microsoft/vscode-azext-utils'; +import { composeArgs, withArg, withNamedArg } from '@microsoft/vscode-processutils'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { createAzureDevCli } from '../utils/azureDevCli'; +import { execAsync } from '../utils/execAsync'; + +export type AzDevEnvValuesResults = Record; + +export interface AzureDevEnvValuesProvider { + getEnvValues(context: IActionContext, configurationFile: vscode.Uri, environmentName: string): Promise; +} + +export class WorkspaceAzureDevEnvValuesProvider implements AzureDevEnvValuesProvider { + public async getEnvValues(context: IActionContext, configurationFile: vscode.Uri, environmentName: string): Promise { + const azureCli = await createAzureDevCli(context); + const configurationFileDirectory = path.dirname(configurationFile.fsPath); + + const args = composeArgs( + withArg('env', 'get-values'), + withNamedArg('--environment', environmentName, { shouldQuote: true }), + withNamedArg('--cwd', configurationFileDirectory, { shouldQuote: true }), + withNamedArg('--output', 'json'), + )(); + + try { + const { stdout } = await execAsync(azureCli.invocation, args, azureCli.spawnOptions(configurationFileDirectory)); + return JSON.parse(stdout) as AzDevEnvValuesResults; + } catch (error) { + // Fallback or handle error if json output is not supported or command fails + // For now, assuming JSON output is supported in recent azd versions + console.error('Failed to get env values', error); + return {}; + } + } +} diff --git a/ext/vscode/src/services/AzureDevExtensionProvider.ts b/ext/vscode/src/services/AzureDevExtensionProvider.ts new file mode 100644 index 00000000000..5d6472a1aff --- /dev/null +++ b/ext/vscode/src/services/AzureDevExtensionProvider.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IActionContext } from '@microsoft/vscode-azext-utils'; +import { composeArgs, withArg, withNamedArg } from '@microsoft/vscode-processutils'; +import { createAzureDevCli } from '../utils/azureDevCli'; +import { execAsync } from '../utils/execAsync'; + +export interface AzureDevExtension { + readonly id: string; + readonly name: string; + readonly version: string; +} + +export type AzDevExtensionListResults = AzureDevExtension[]; + +export interface AzureDevExtensionProvider { + getExtensionListResults(context: IActionContext): Promise; +} + +export class WorkspaceAzureDevExtensionProvider implements AzureDevExtensionProvider { + public async getExtensionListResults(context: IActionContext): Promise { + const azureCli = await createAzureDevCli(context); + + const args = composeArgs( + withArg('extension', 'list'), + withNamedArg('--output', 'json'), + )(); + + try { + const { stdout } = await execAsync(azureCli.invocation, args, azureCli.spawnOptions()); + return JSON.parse(stdout) as AzDevExtensionListResults; + } catch { + // If command fails (e.g. not supported or no extensions), return empty list + return []; + } + } +} diff --git a/ext/vscode/src/services/AzureDevTemplateProvider.ts b/ext/vscode/src/services/AzureDevTemplateProvider.ts new file mode 100644 index 00000000000..9bf53b9e5d0 --- /dev/null +++ b/ext/vscode/src/services/AzureDevTemplateProvider.ts @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as vscode from 'vscode'; + +export interface Template { + id: string; + title: string; + description: string; + source: string; + preview?: string; + author: string; + authorUrl?: string; + tags?: string[]; + languages?: string[]; + frameworks?: string[]; + azureServices?: string[]; + IaC?: string[]; +} + +export interface TemplateCategory { + name: string; + displayName: string; + icon: string; + filter: (template: Template) => boolean; +} + +export class AzureDevTemplateProvider { + private templatesCache: Template[] | undefined; + // eslint-disable-next-line @typescript-eslint/naming-convention + private readonly TEMPLATES_URL = 'https://raw.githubusercontent.com/Azure/awesome-azd/main/website/static/templates.json'; + // eslint-disable-next-line @typescript-eslint/naming-convention + private readonly CACHE_DURATION_MS = 3600000; // 1 hour + private lastFetchTime: number = 0; + + private readonly categories: TemplateCategory[] = [ + { + name: 'ai', + displayName: 'AI & Machine Learning', + icon: '🤖', + filter: (t) => t.tags?.some(tag => ['ai', 'gpt', 'aicollection'].includes(tag)) ?? false + }, + { + name: 'webapp', + displayName: 'Web Applications', + icon: '🌐', + filter: (t) => t.tags?.some(tag => ['webapps', 'reactjs', 'angular', 'vuejs'].includes(tag)) ?? false + }, + { + name: 'api', + displayName: 'APIs & Functions', + icon: '🔧', + filter: (t) => (t.tags?.includes('functions') || t.azureServices?.includes('functions')) ?? false + }, + { + name: 'container', + displayName: 'Containers & Kubernetes', + icon: '📦', + filter: (t) => (t.tags?.includes('kubernetes') || t.azureServices?.some(s => ['aks', 'aca'].includes(s))) ?? false + }, + { + name: 'database', + displayName: 'Databases & Storage', + icon: '💾', + filter: (t) => t.azureServices?.some(s => ['cosmosdb', 'azuresql', 'azuredb-postgreSQL', 'azuredb-mySQL'].includes(s)) ?? false + }, + { + name: 'starter', + displayName: 'Starter Templates', + icon: '🚀', + filter: (t) => t.title.toLowerCase().includes('starter') || t.title.toLowerCase().includes('quickstart') + } + ]; + + async getTemplates(forceRefresh: boolean = false): Promise { + const now = Date.now(); + const cacheExpired = (now - this.lastFetchTime) > this.CACHE_DURATION_MS; + + if (!this.templatesCache || forceRefresh || cacheExpired) { + try { + const response = await fetch(this.TEMPLATES_URL); + if (!response.ok) { + throw new Error(`Failed to fetch templates: ${response.statusText}`); + } + this.templatesCache = await response.json() as Template[]; + this.lastFetchTime = now; + } catch (error) { + vscode.window.showErrorMessage(`Failed to load templates: ${error instanceof Error ? error.message : 'Unknown error'}`); + return this.templatesCache || []; + } + } + + return this.templatesCache || []; + } + + async getAITemplates(): Promise { + const templates = await this.getTemplates(); + return templates.filter(t => t.tags?.includes('aicollection')); + } + + async getTemplatesByCategory(categoryName: string): Promise { + const templates = await this.getTemplates(); + const category = this.categories.find(c => c.name === categoryName); + if (!category) { + return []; + } + return templates.filter(category.filter); + } + + async searchTemplates(query: string): Promise { + const templates = await this.getTemplates(); + const lowerQuery = query.toLowerCase(); + + return templates.filter(t => { + const titleMatch = t.title.toLowerCase().includes(lowerQuery); + const descMatch = t.description?.toLowerCase().includes(lowerQuery); + const tagMatch = t.tags?.some(tag => tag.toLowerCase().includes(lowerQuery)); + const langMatch = t.languages?.some(lang => lang.toLowerCase().includes(lowerQuery)); + const serviceMatch = t.azureServices?.some(svc => svc.toLowerCase().includes(lowerQuery)); + + return titleMatch || descMatch || tagMatch || langMatch || serviceMatch; + }); + } + + getCategories(): TemplateCategory[] { + return this.categories; + } + + extractTemplatePath(sourceUrl: string): string { + // Convert GitHub URL to format accepted by azd init + // https://github.com/Azure-Samples/todo-csharp-cosmos-sql -> Azure-Samples/todo-csharp-cosmos-sql + const match = sourceUrl.match(/github\.com\/([^/]+\/[^/]+)/); + return match ? match[1] : sourceUrl; + } + + async getTemplateCount(): Promise { + const templates = await this.getTemplates(); + return templates.length; + } +} diff --git a/ext/vscode/src/telemetry/telemetryId.ts b/ext/vscode/src/telemetry/telemetryId.ts index bdaf5f407c5..2702bafc78f 100644 --- a/ext/vscode/src/telemetry/telemetryId.ts +++ b/ext/vscode/src/telemetry/telemetryId.ts @@ -61,6 +61,18 @@ export enum TelemetryId { // Reported when 'env list' CLI command is invoked. EnvListCli = 'azure-dev.commands.cli.env-list.task', + // Reported when 'extension install' CLI command is invoked. + ExtensionInstallCli = 'azure-dev.commands.cli.extension-install.task', + + // Reported when 'extension uninstall' CLI command is invoked. + ExtensionUninstallCli = 'azure-dev.commands.cli.extension-uninstall.task', + + // Reported when 'extension upgrade' CLI command is invoked. + ExtensionUpgradeCli = 'azure-dev.commands.cli.extension-upgrade.task', + + // Reported when 'extension source add' CLI command is invoked. + ExtensionSourceAddCli = 'azure-dev.commands.cli.extension-source-add.task', + // Reported when the product evaluates whether to prompt the user for a survey. // We capture // - whether the user was already offered the survey, @@ -74,6 +86,7 @@ export enum TelemetryId { WorkspaceViewApplicationResolve = 'azure-dev.views.workspace.application.resolve', WorkspaceViewEnvironmentResolve = 'azure-dev.views.workspace.environment.resolve', + WorkspaceViewExtensionResolve = 'azure-dev.views.workspace.extension.resolve', // Reported when diagnostics are provided on an azure.yaml document AzureYamlProvideDiagnostics = 'azure-dev.azureYaml.provideDiagnostics', diff --git a/ext/vscode/src/test/suite/unit/addService.test.ts b/ext/vscode/src/test/suite/unit/addService.test.ts new file mode 100644 index 00000000000..61a5177d945 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/addService.test.ts @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import { addService } from '../../../commands/addService'; +import { IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzureDevCliModel } from '../../../views/workspace/AzureDevCliModel'; + +suite('addService', () => { + let sandbox: sinon.SinonSandbox; + let mockContext: IActionContext; + let showInputBoxStub: sinon.SinonStub; + let showQuickPickStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; + let showInformationMessageStub: sinon.SinonStub; + let openTextDocumentStub: sinon.SinonStub; + let applyEditStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + mockContext = {} as IActionContext; + showInputBoxStub = sandbox.stub(vscode.window, 'showInputBox'); + showQuickPickStub = sandbox.stub(vscode.window, 'showQuickPick'); + showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage'); + showInformationMessageStub = sandbox.stub(vscode.window, 'showInformationMessage'); + openTextDocumentStub = sandbox.stub(vscode.workspace, 'openTextDocument'); + applyEditStub = sandbox.stub(vscode.workspace, 'applyEdit'); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('returns early when user cancels service name input', async () => { + const mockNode = { + context: { + configurationFile: vscode.Uri.file('/test/azure.yaml') + } + } as AzureDevCliModel; + + showInputBoxStub.resolves(undefined); + + await addService(mockContext, mockNode); + + assert.strictEqual(showQuickPickStub.called, false, 'showQuickPick should not be called if service name is cancelled'); + }); + + test('validates service name input correctly', async () => { + const mockNode = { + context: { + configurationFile: vscode.Uri.file('/test/azure.yaml') + } + } as AzureDevCliModel; + + showInputBoxStub.resolves('my-service'); + + // Call the function to trigger showInputBox + await addService(mockContext, mockNode); + + // Get the validator function + assert.ok(showInputBoxStub.called, 'showInputBox should be called'); + const inputBoxOptions = showInputBoxStub.firstCall?.args[0] as vscode.InputBoxOptions; + const validator = inputBoxOptions?.validateInput; + + assert.ok(validator, 'Validator should be provided'); + + if (validator) { + // Valid names + assert.strictEqual(validator('my-service'), undefined); + assert.strictEqual(validator('my_service'), undefined); + assert.strictEqual(validator('myService123'), undefined); + + // Invalid names + assert.ok(validator(''), 'Empty string should be invalid'); + assert.ok(validator('my service'), 'Space should be invalid'); + assert.ok(validator('my@service'), 'Special character should be invalid'); + } + }); + + test('returns early when user cancels language selection', async () => { + const mockNode = { + context: { + configurationFile: vscode.Uri.file('/test/azure.yaml') + } + } as AzureDevCliModel; + + showInputBoxStub.resolves('my-service'); + showQuickPickStub.onFirstCall().resolves(undefined); + + await addService(mockContext, mockNode); + + assert.strictEqual(showQuickPickStub.callCount, 1, 'Should only call showQuickPick once for language'); + }); + + test('returns early when user cancels host selection', async () => { + const mockNode = { + context: { + configurationFile: vscode.Uri.file('/test/azure.yaml') + } + } as AzureDevCliModel; + + showInputBoxStub.resolves('my-service'); + showQuickPickStub.onFirstCall().resolves('python'); + showQuickPickStub.onSecondCall().resolves(undefined); + + await addService(mockContext, mockNode); + + assert.strictEqual(showQuickPickStub.callCount, 2, 'Should call showQuickPick twice (language and host)'); + assert.strictEqual(openTextDocumentStub.called, false, 'Should not open document if host is cancelled'); + }); + + test('adds service with correct YAML structure when all inputs provided', async () => { + const mockNode = { + context: { + configurationFile: vscode.Uri.file('/test/azure.yaml') + } + } as AzureDevCliModel; + + const mockDocument = { + getText: () => `name: test-app\nservices:\n web:\n project: ./web\n language: ts\n host: containerapp\n`, + positionAt: (offset: number) => new vscode.Position(0, 0) + }; + + showInputBoxStub.resolves('api'); + showQuickPickStub.onFirstCall().resolves('python'); + showQuickPickStub.onSecondCall().resolves({ label: 'containerapp', description: 'Azure Container Apps' }); + openTextDocumentStub.resolves(mockDocument); + applyEditStub.resolves(true); + + await addService(mockContext, mockNode); + + assert.ok(applyEditStub.called, 'applyEdit should be called'); + assert.ok(showInformationMessageStub.called, 'Success message should be shown'); + + const successMessage = showInformationMessageStub.firstCall.args[0] as string; + assert.ok(successMessage.includes('api'), 'Success message should include service name'); + }); + + test('shows error when services section not found in azure.yaml', async () => { + const mockNode = { + context: { + configurationFile: vscode.Uri.file('/test/azure.yaml') + } + } as AzureDevCliModel; + + const mockDocument = { + getText: () => `name: test-app\n`, + positionAt: (offset: number) => new vscode.Position(0, 0) + }; + + showInputBoxStub.resolves('api'); + showQuickPickStub.onFirstCall().resolves('python'); + showQuickPickStub.onSecondCall().resolves({ label: 'containerapp', description: 'Azure Container Apps' }); + openTextDocumentStub.resolves(mockDocument); + + await addService(mockContext, mockNode); + + assert.ok(showErrorMessageStub.called, 'Error message should be shown'); + const errorMessage = showErrorMessageStub.firstCall.args[0] as string; + assert.ok(errorMessage.includes('No services section'), 'Error should mention missing services section'); + }); + + test('searches for azure.yaml when node has no configuration file', async () => { + const findFilesStub = sandbox.stub(vscode.workspace, 'findFiles'); + sandbox.stub(vscode.workspace, 'workspaceFolders').get(() => [ + { uri: vscode.Uri.file('/test'), name: 'test', index: 0 } + ]); + + findFilesStub.resolves([vscode.Uri.file('/test/azure.yaml')]); + + const mockDocument = { + getText: () => `name: test-app\nservices:\n web:\n project: ./web\n`, + positionAt: (offset: number) => new vscode.Position(0, 0) + }; + + showInputBoxStub.resolves('api'); + showQuickPickStub.onFirstCall().resolves('python'); + showQuickPickStub.onSecondCall().resolves({ label: 'function', description: 'Azure Functions' }); + openTextDocumentStub.resolves(mockDocument); + applyEditStub.resolves(true); + + await addService(mockContext); + + assert.ok(findFilesStub.called, 'Should search for azure.yaml files'); + assert.ok(openTextDocumentStub.called, 'Should open the found azure.yaml file'); + }); + + test('shows error when no workspace folder is open', async () => { + sandbox.stub(vscode.workspace, 'workspaceFolders').get(() => undefined); + + await addService(mockContext); + + assert.ok(showErrorMessageStub.called, 'Error message should be shown'); + const errorMessage = showErrorMessageStub.firstCall.args[0] as string; + assert.ok(errorMessage.includes('No workspace folder'), 'Error should mention no workspace folder'); + }); + + test('shows error when no azure.yaml found in workspace', async () => { + const findFilesStub = sandbox.stub(vscode.workspace, 'findFiles'); + sandbox.stub(vscode.workspace, 'workspaceFolders').get(() => [ + { uri: vscode.Uri.file('/test'), name: 'test', index: 0 } + ]); + + findFilesStub.resolves([]); + + await addService(mockContext); + + assert.ok(showErrorMessageStub.called, 'Error message should be shown'); + const errorMessage = showErrorMessageStub.firstCall.args[0] as string; + assert.ok(errorMessage.includes('No azure.yaml file found'), 'Error should mention no azure.yaml found'); + }); + + test('generates correct service snippet with different host types', async () => { + const mockNode = { + context: { + configurationFile: vscode.Uri.file('/test/azure.yaml') + } + } as AzureDevCliModel; + + const mockDocument = { + getText: () => `name: test-app\nservices:\n web:\n project: ./web\n`, + positionAt: (offset: number) => new vscode.Position(0, 0) + }; + + // Test with different host types + const hostTypes = [ + { label: 'containerapp', description: 'Azure Container Apps' }, + { label: 'appservice', description: 'Azure App Service' }, + { label: 'function', description: 'Azure Functions' } + ]; + + for (const host of hostTypes) { + showInputBoxStub.resolves('api'); + showQuickPickStub.onFirstCall().resolves('python'); + showQuickPickStub.onSecondCall().resolves(host); + openTextDocumentStub.resolves(mockDocument); + applyEditStub.resolves(true); + + await addService(mockContext, mockNode); + + assert.ok(applyEditStub.called, `applyEdit should be called for host ${host.label}`); + + // Reset stubs for next iteration + applyEditStub.resetHistory(); + showQuickPickStub.resetHistory(); + } + }); +}); diff --git a/ext/vscode/src/test/suite/unit/azureDevTemplateProvider.test.ts b/ext/vscode/src/test/suite/unit/azureDevTemplateProvider.test.ts new file mode 100644 index 00000000000..ccaf49a73b0 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/azureDevTemplateProvider.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import { AzureDevTemplateProvider } from '../../../services/AzureDevTemplateProvider'; + +suite('AzureDevTemplateProvider', () => { + let provider: AzureDevTemplateProvider; + + setup(() => { + provider = new AzureDevTemplateProvider(); + }); + + test('getTemplates returns array of templates', async () => { + const templates = await provider.getTemplates(); + + assert.ok(Array.isArray(templates), 'Should return an array'); + if (templates.length > 0) { + const template = templates[0]; + assert.ok(template.id, 'Template should have an id'); + assert.ok(template.title, 'Template should have a title'); + assert.ok(template.description, 'Template should have a description'); + assert.ok(template.source, 'Template should have a source'); + } + }); + + test('getAITemplates returns only AI-tagged templates', async () => { + const aiTemplates = await provider.getAITemplates(); + + assert.ok(Array.isArray(aiTemplates), 'Should return an array'); + aiTemplates.forEach(template => { + assert.ok( + template.tags?.includes('aicollection'), + `Template "${template.title}" should have aicollection tag` + ); + }); + }); + + test('searchTemplates filters by query', async () => { + const searchResults = await provider.searchTemplates('react'); + + assert.ok(Array.isArray(searchResults), 'Should return an array'); + searchResults.forEach(template => { + const matchesQuery = + template.title.toLowerCase().includes('react') || + template.description?.toLowerCase().includes('react') || + template.tags?.some(tag => tag.toLowerCase().includes('react')) || + template.languages?.some(lang => lang.toLowerCase().includes('react')) || + template.frameworks?.some(fw => fw.toLowerCase().includes('react')); + + assert.ok(matchesQuery, `Template "${template.title}" should match "react" query`); + }); + }); + + test('getTemplatesByCategory returns correct category templates', async () => { + const categories = provider.getCategories(); + assert.ok(categories.length > 0, 'Should have categories'); + + const firstCategory = categories[0]; + const categoryTemplates = await provider.getTemplatesByCategory(firstCategory.name); + + assert.ok(Array.isArray(categoryTemplates), 'Should return an array'); + }); + + test('getCategories returns array of categories', () => { + const categories = provider.getCategories(); + + assert.ok(Array.isArray(categories), 'Should return an array'); + assert.ok(categories.length > 0, 'Should have at least one category'); + + categories.forEach(category => { + assert.ok(category.name, 'Category should have a name'); + assert.ok(category.displayName, 'Category should have a display name'); + assert.ok(category.icon, 'Category should have an icon'); + assert.ok(typeof category.filter === 'function', 'Category should have a filter function'); + }); + }); + + test('extractTemplatePath extracts correct path from GitHub URL', () => { + const testCases = [ + { + input: 'https://github.com/Azure-Samples/todo-csharp-cosmos-sql', + expected: 'Azure-Samples/todo-csharp-cosmos-sql' + }, + { + input: 'https://github.com/Azure/azure-dev', + expected: 'Azure/azure-dev' + } + ]; + + testCases.forEach(testCase => { + const result = provider.extractTemplatePath(testCase.input); + assert.strictEqual(result, testCase.expected, `Should extract path from ${testCase.input}`); + }); + }); + + test('getTemplateCount returns number of templates', async () => { + const count = await provider.getTemplateCount(); + + assert.ok(typeof count === 'number', 'Should return a number'); + assert.ok(count >= 0, 'Count should be non-negative'); + }); + + test('caching works - second call is faster', async () => { + // First call - fetches from network + const start1 = Date.now(); + await provider.getTemplates(); + const duration1 = Date.now() - start1; + + // Second call - uses cache + const start2 = Date.now(); + await provider.getTemplates(); + const duration2 = Date.now() - start2; + + // Cache should be significantly faster (at least 10x) + // Note: This is a heuristic and may not always pass due to network conditions + assert.ok(duration2 < duration1 / 5 || duration2 < 10, + `Second call should be faster (${duration2}ms) than first (${duration1}ms)`); + }); + + test('forceRefresh parameter refreshes cache', async () => { + // Load into cache + const templates1 = await provider.getTemplates(); + assert.ok(templates1.length > 0, 'Should have templates'); + + // Force refresh + const templates2 = await provider.getTemplates(true); + assert.ok(templates2.length > 0, 'Should have templates after refresh'); + + // Both should have same data + assert.strictEqual(templates1.length, templates2.length, 'Should have same number of templates'); + }); +}); diff --git a/ext/vscode/src/test/suite/unit/azureYamlCodeAction.test.ts b/ext/vscode/src/test/suite/unit/azureYamlCodeAction.test.ts new file mode 100644 index 00000000000..4f8c1c72436 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/azureYamlCodeAction.test.ts @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { assert } from 'chai'; +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import { AzureYamlCodeActionProvider } from '../../../language/AzureYamlCodeActionProvider'; + +suite('AzureYamlCodeActionProvider', () => { + let provider: AzureYamlCodeActionProvider; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + provider = new AzureYamlCodeActionProvider(); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite('provideCodeActions', () => { + test('provides create folder action for missing project path', async () => { + const content = 'services:\n api:\n project: ./nonexistent'; + const document = await createTestDocument(content); + const range = new vscode.Range(2, 4, 2, 28); + + const diagnostic = new vscode.Diagnostic( + range, + 'The project path must be an existing folder or file path relative to the azure.yaml file.', + vscode.DiagnosticSeverity.Error + ); + + const context: vscode.CodeActionContext = { + diagnostics: [diagnostic], + only: undefined, + triggerKind: vscode.CodeActionTriggerKind.Automatic + }; + + const tokenSource = new vscode.CancellationTokenSource(); + const result = await provider.provideCodeActions( + document, + range, + context, + tokenSource.token + ); + + assert.ok(result); + assert.ok(result.length > 0); + + const createFolderAction = result.find(a => a.title.includes('Create folder')); + assert.ok(createFolderAction); + assert.equal(createFolderAction.kind, vscode.CodeActionKind.QuickFix); + }); + + test('provides browse for folder action for missing project path', async () => { + const content = 'services:\n api:\n project: ./nonexistent'; + const document = await createTestDocument(content); + const range = new vscode.Range(2, 4, 2, 28); + + const diagnostic = new vscode.Diagnostic( + range, + 'The project path must be an existing folder or file path relative to the azure.yaml file.', + vscode.DiagnosticSeverity.Error + ); + + const context: vscode.CodeActionContext = { + diagnostics: [diagnostic], + only: undefined, + triggerKind: vscode.CodeActionTriggerKind.Automatic + }; + + const tokenSource = new vscode.CancellationTokenSource(); + const result = await provider.provideCodeActions( + document, + range, + context, + tokenSource.token + ); + + const browseAction = result.find(a => a.title.includes('Browse')); + assert.ok(browseAction); + assert.equal(browseAction.kind, vscode.CodeActionKind.QuickFix); + }); + + test('provides add language actions', async () => { + const content = 'services:\n api:\n project: ./api'; + const document = await createTestDocument(content); + const range = new vscode.Range(1, 2, 1, 5); + + const diagnostic = new vscode.Diagnostic( + range, + 'Service is missing language property', + vscode.DiagnosticSeverity.Information + ); + + const context: vscode.CodeActionContext = { + diagnostics: [diagnostic], + only: undefined, + triggerKind: vscode.CodeActionTriggerKind.Automatic + }; + + const tokenSource = new vscode.CancellationTokenSource(); + const result = await provider.provideCodeActions( + document, + range, + context, + tokenSource.token + ); + + const languageActions = result.filter(a => a.title.includes('Add language')); + assert.ok(languageActions.length > 0); + assert.ok(languageActions.some(a => a.title.includes('python'))); + assert.ok(languageActions.some(a => a.title.includes('js'))); + }); + + test('provides add host actions', async () => { + const content = 'services:\n api:\n project: ./api'; + const document = await createTestDocument(content); + const range = new vscode.Range(1, 2, 1, 5); + + const diagnostic = new vscode.Diagnostic( + range, + 'Service is missing host property', + vscode.DiagnosticSeverity.Information + ); + + const context: vscode.CodeActionContext = { + diagnostics: [diagnostic], + only: undefined, + triggerKind: vscode.CodeActionTriggerKind.Automatic + }; + + const tokenSource = new vscode.CancellationTokenSource(); + const result = await provider.provideCodeActions( + document, + range, + context, + tokenSource.token + ); + + const hostActions = result.filter(a => a.title.includes('Add host')); + assert.ok(hostActions.length > 0); + assert.ok(hostActions.some(a => a.title.includes('Container Apps'))); + assert.ok(hostActions.some(a => a.title.includes('App Service'))); + }); + + test('provides add new service refactoring action', async () => { + const content = 'services:\n api:\n project: ./api'; + const document = await createTestDocument(content); + const range = new vscode.Range(0, 0, 0, 0); + + const context: vscode.CodeActionContext = { + diagnostics: [], + only: undefined, + triggerKind: vscode.CodeActionTriggerKind.Automatic + }; + + const tokenSource = new vscode.CancellationTokenSource(); + const result = await provider.provideCodeActions( + document, + range, + context, + tokenSource.token + ); + + const addServiceAction = result.find(a => a.title.includes('Add new service')); + assert.ok(addServiceAction); + assert.equal(addServiceAction.kind, vscode.CodeActionKind.Refactor); + }); + + test('code actions have correct properties', async () => { + const content = 'services:\n api:\n project: ./nonexistent'; + const document = await createTestDocument(content); + const range = new vscode.Range(2, 4, 2, 28); + + const diagnostic = new vscode.Diagnostic( + range, + 'The project path must be an existing folder or file path relative to the azure.yaml file.', + vscode.DiagnosticSeverity.Error + ); + + const context: vscode.CodeActionContext = { + diagnostics: [diagnostic], + only: undefined, + triggerKind: vscode.CodeActionTriggerKind.Automatic + }; + + const tokenSource = new vscode.CancellationTokenSource(); + const result = await provider.provideCodeActions( + document, + range, + context, + tokenSource.token + ); + + const createAction = result.find(a => a.title.includes('Create folder')); + assert.ok(createAction); + assert.ok(createAction.isPreferred); // Should be the preferred action + assert.ok(createAction.diagnostics); + assert.equal(createAction.diagnostics.length, 1); + }); + }); + + async function createTestDocument(content: string): Promise { + const uri = vscode.Uri.parse('untitled:test-azure.yaml'); + const doc = await vscode.workspace.openTextDocument(uri); + const edit = new vscode.WorkspaceEdit(); + edit.insert(uri, new vscode.Position(0, 0), content); + await vscode.workspace.applyEdit(edit); + return doc; + } +}); diff --git a/ext/vscode/src/test/suite/unit/azureYamlCompletion.test.ts b/ext/vscode/src/test/suite/unit/azureYamlCompletion.test.ts new file mode 100644 index 00000000000..f538da4c589 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/azureYamlCompletion.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { assert } from 'chai'; +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import { AzureYamlCompletionProvider } from '../../../language/AzureYamlCompletionProvider'; + +suite('AzureYamlCompletionProvider', () => { + let provider: AzureYamlCompletionProvider; + let document: vscode.TextDocument; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + provider = new AzureYamlCompletionProvider(); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite('provideCompletionItems', () => { + test('provides host type completions after "host:"', async () => { + const content = 'services:\n api:\n host:'; + document = await createTestDocument(content); + const position = new vscode.Position(2, 9); // After "host:" + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideCompletionItems( + document, + position, + tokenSource.token, + { triggerKind: vscode.CompletionTriggerKind.Invoke, triggerCharacter: ':' } + ); + + assert.ok(result); + const items = Array.isArray(result) ? result : (result as vscode.CompletionList)?.items || []; + assert.ok(items.length > 0); + + const hostLabels = items.map(i => i.label); + assert.include(hostLabels, 'containerapp'); + assert.include(hostLabels, 'appservice'); + assert.include(hostLabels, 'function'); + }); + + test('provides hook type completions in hooks section', async () => { + const content = 'services:\n api:\n hooks:\n '; + document = await createTestDocument(content); + const position = new vscode.Position(3, 6); // In hooks section + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideCompletionItems( + document, + position, + tokenSource.token, + { triggerKind: vscode.CompletionTriggerKind.Invoke, triggerCharacter: '\n' } + ); + + assert.ok(result); + const items = Array.isArray(result) ? result : (result as vscode.CompletionList)?.items || []; + + const hookLabels = items.map(i => i.label); + assert.include(hookLabels, 'prerestore'); + assert.include(hookLabels, 'postprovision'); + assert.include(hookLabels, 'predeploy'); + }); + + test('provides service property completions', async () => { + const content = 'services:\n api:\n '; + document = await createTestDocument(content); + const position = new vscode.Position(2, 4); // Under service + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideCompletionItems( + document, + position, + tokenSource.token, + { triggerKind: vscode.CompletionTriggerKind.Invoke, triggerCharacter: '\n' } + ); + + assert.ok(result); + const items = Array.isArray(result) ? result : (result as vscode.CompletionList)?.items || []; + + const propertyLabels = items.map(i => i.label); + assert.include(propertyLabels, 'project'); + assert.include(propertyLabels, 'language'); + assert.include(propertyLabels, 'host'); + }); + + test('provides top-level property completions', async () => { + const content = ''; + document = await createTestDocument(content); + const position = new vscode.Position(0, 0); // At root + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideCompletionItems( + document, + position, + tokenSource.token, + { triggerKind: vscode.CompletionTriggerKind.Invoke, triggerCharacter: '\n' } + ); + + assert.ok(result); + const items = Array.isArray(result) ? result : (result as vscode.CompletionList)?.items || []; + + const propertyLabels = items.map(i => i.label); + assert.include(propertyLabels, 'name'); + assert.include(propertyLabels, 'services'); + }); + + test('completion items have correct kind', async () => { + const content = 'services:\n api:\n host:'; + document = await createTestDocument(content); + const position = new vscode.Position(2, 9); + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideCompletionItems( + document, + position, + tokenSource.token, + { triggerKind: vscode.CompletionTriggerKind.Invoke, triggerCharacter: ':' } + ); + + const items = Array.isArray(result) ? result : (result as vscode.CompletionList)?.items || []; + const firstItem = items[0]; + + assert.equal(firstItem.kind, vscode.CompletionItemKind.Value); + }); + + test('completion items have documentation', async () => { + const content = 'services:\n api:\n host:'; + document = await createTestDocument(content); + const position = new vscode.Position(2, 9); + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideCompletionItems( + document, + position, + tokenSource.token, + { triggerKind: vscode.CompletionTriggerKind.Invoke, triggerCharacter: ':' } + ); + + const items = Array.isArray(result) ? result : (result as vscode.CompletionList)?.items || []; + const firstItem = items[0]; + + assert.ok(firstItem.documentation); + }); + }); + + async function createTestDocument(content: string): Promise { + const uri = vscode.Uri.parse('untitled:test-azure.yaml'); + const doc = await vscode.workspace.openTextDocument(uri); + const edit = new vscode.WorkspaceEdit(); + edit.insert(uri, new vscode.Position(0, 0), content); + await vscode.workspace.applyEdit(edit); + return doc; + } +}); diff --git a/ext/vscode/src/test/suite/unit/azureYamlDiagnostics.test.ts b/ext/vscode/src/test/suite/unit/azureYamlDiagnostics.test.ts new file mode 100644 index 00000000000..53099a6fb58 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/azureYamlDiagnostics.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { assert } from 'chai'; +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import { AzExtFsExtra } from '@microsoft/vscode-azext-utils'; +import { AzureYamlDiagnosticProvider } from '../../../language/AzureYamlDiagnosticProvider'; + +suite('AzureYamlDiagnosticProvider - Enhanced Validation', () => { + let provider: AzureYamlDiagnosticProvider; + let sandbox: sinon.SinonSandbox; + const selector: vscode.DocumentSelector = { + language: 'yaml', + scheme: 'file', + pattern: '**/azure.{yml,yaml}' + }; + + setup(() => { + sandbox = sinon.createSandbox(); + provider = new AzureYamlDiagnosticProvider(selector); + }); + + teardown(() => { + sandbox.restore(); + provider.dispose(); + }); + + suite('validateYamlStructure', () => { + test('warns about missing name property', async () => { + const content = 'services:\n api:\n project: ./api'; + const document = await createTestDocument(content, 'azure.yaml'); + + const diagnostics = await provider.provideDiagnostics(document); + + assert.ok(diagnostics); + const nameDiagnostic = diagnostics.find(d => d.message.includes('name')); + assert.ok(nameDiagnostic); + assert.equal(nameDiagnostic.severity, vscode.DiagnosticSeverity.Warning); + }); + + test('provides info for missing language property', async () => { + const content = 'name: myapp\nservices:\n api:\n project: ./api\n host: containerapp'; + const document = await createTestDocument(content, 'azure.yaml'); + + const diagnostics = await provider.provideDiagnostics(document); + + assert.ok(diagnostics); + const languageDiagnostic = diagnostics.find(d => d.message.includes('language')); + assert.ok(languageDiagnostic); + assert.equal(languageDiagnostic.severity, vscode.DiagnosticSeverity.Information); + }); + + test('provides info for missing host property', async () => { + const content = 'name: myapp\nservices:\n api:\n project: ./api\n language: python'; + const document = await createTestDocument(content, 'azure.yaml'); + + const diagnostics = await provider.provideDiagnostics(document); + + assert.ok(diagnostics); + const hostDiagnostic = diagnostics.find(d => d.message.includes('host')); + assert.ok(hostDiagnostic); + assert.equal(hostDiagnostic.severity, vscode.DiagnosticSeverity.Information); + }); + + test('warns about invalid host type', async () => { + const content = 'name: myapp\nservices:\n api:\n project: ./api\n host: invalidhost'; + const document = await createTestDocument(content, 'azure.yaml'); + + const diagnostics = await provider.provideDiagnostics(document); + + assert.ok(diagnostics); + const hostDiagnostic = diagnostics.find(d => d.message.includes('Invalid host type')); + assert.ok(hostDiagnostic); + assert.equal(hostDiagnostic.severity, vscode.DiagnosticSeverity.Warning); + assert.ok(hostDiagnostic.message.includes('containerapp')); + assert.ok(hostDiagnostic.message.includes('appservice')); + }); + + test('provides info for project path without ./ prefix', async () => { + const content = 'name: myapp\nservices:\n api:\n project: api'; + const document = await createTestDocument(content, 'azure.yaml'); + + const diagnostics = await provider.provideDiagnostics(document); + + assert.ok(diagnostics); + const projectDiagnostic = diagnostics.find(d => d.message.includes('should start with')); + assert.ok(projectDiagnostic); + assert.equal(projectDiagnostic.severity, vscode.DiagnosticSeverity.Information); + }); + + test('accepts valid host types', async () => { + const validHosts = ['containerapp', 'appservice', 'function', 'aks', 'staticwebapp']; + + for (const host of validHosts) { + const content = `name: myapp\nservices:\n api:\n project: ./api\n host: ${host}`; + const document = await createTestDocument(content, 'azure.yaml'); + + const diagnostics = await provider.provideDiagnostics(document); + + const hostDiagnostic = diagnostics?.find(d => d.message.includes('Invalid host type')); + assert.isUndefined(hostDiagnostic, `${host} should be valid`); + } + }); + + test('handles multiple services', async () => { + const content = 'name: myapp\nservices:\n api:\n project: ./api\n web:\n project: ./web'; + const document = await createTestDocument(content, 'azure.yaml'); + + const diagnostics = await provider.provideDiagnostics(document); + + assert.ok(diagnostics); + // Should have diagnostics for both services missing language/host + const apiDiagnostics = diagnostics.filter(d => d.message.includes('api')); + const webDiagnostics = diagnostics.filter(d => d.message.includes('web')); + assert.ok(apiDiagnostics.length > 0 || webDiagnostics.length > 0); + }); + + test('handles malformed YAML gracefully', async () => { + const content = 'name: myapp\nservices\n api:'; // Missing colon after services + const document = await createTestDocument(content, 'azure.yaml'); + + // Should not throw + const diagnostics = await provider.provideDiagnostics(document); + + // May return undefined or empty array for malformed YAML + assert.ok(diagnostics === undefined || Array.isArray(diagnostics)); + }); + + test('returns no diagnostics for well-formed azure.yaml', async () => { + // Mock file system to make project path appear to exist + sandbox.stub(AzExtFsExtra, 'pathExists').resolves(true); + + const content = 'name: myapp\nservices:\n api:\n project: ./api\n language: python\n host: containerapp'; + const document = await createTestDocument(content, 'azure.yaml'); + + const diagnostics = await provider.provideDiagnostics(document); + + // Should have no errors or warnings + const errors = diagnostics?.filter(d => d.severity === vscode.DiagnosticSeverity.Error) || []; + const warnings = diagnostics?.filter(d => d.severity === vscode.DiagnosticSeverity.Warning) || []; + + assert.equal(errors.length, 0); + assert.equal(warnings.length, 0); + }); + }); + + async function createTestDocument(content: string, filename: string): Promise { + const uri = vscode.Uri.file(`/test/${filename}`); + + // Create a mock document + const document = { + uri, + fileName: uri.fsPath, + languageId: 'yaml', + version: 1, + lineCount: content.split('\n').length, + getText: (range?: vscode.Range) => { + if (!range) { + return content; + } + const lines = content.split('\n'); + return lines.slice(range.start.line, range.end.line + 1).join('\n'); + }, + lineAt: (line: number) => { + const lines = content.split('\n'); + return { + text: lines[line] || '', + lineNumber: line, + range: new vscode.Range(line, 0, line, lines[line]?.length || 0), + rangeIncludingLineBreak: new vscode.Range(line, 0, line + 1, 0), + firstNonWhitespaceCharacterIndex: (lines[line] || '').search(/\S/), + isEmptyOrWhitespace: !(lines[line] || '').trim() + }; + }, + positionAt: (offset: number) => { + const lines = content.split('\n'); + let currentOffset = 0; + for (let i = 0; i < lines.length; i++) { + if (currentOffset + lines[i].length >= offset) { + return new vscode.Position(i, offset - currentOffset); + } + currentOffset += lines[i].length + 1; // +1 for newline + } + return new vscode.Position(lines.length - 1, 0); + }, + offsetAt: (position: vscode.Position) => { + const lines = content.split('\n'); + let offset = 0; + for (let i = 0; i < position.line; i++) { + offset += lines[i].length + 1; + } + return offset + position.character; + }, + save: async () => true, + eol: vscode.EndOfLine.LF, + isDirty: false, + isClosed: false, + isUntitled: false, + validateRange: (range: vscode.Range) => range, + validatePosition: (position: vscode.Position) => position, + getWordRangeAtPosition: (position: vscode.Position) => undefined + } as unknown as vscode.TextDocument; + + return document; + } +}); diff --git a/ext/vscode/src/test/suite/unit/azureYamlHover.test.ts b/ext/vscode/src/test/suite/unit/azureYamlHover.test.ts new file mode 100644 index 00000000000..0f508ec7191 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/azureYamlHover.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { assert } from 'chai'; +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import { AzureYamlHoverProvider } from '../../../language/AzureYamlHoverProvider'; + +suite('AzureYamlHoverProvider', () => { + let provider: AzureYamlHoverProvider; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + provider = new AzureYamlHoverProvider(); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite('provideHover', () => { + test('provides hover for "host" keyword', async () => { + const content = 'services:\n api:\n host: containerapp'; + const document = await createTestDocument(content); + const position = new vscode.Position(2, 6); // On "host" + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideHover( + document, + position, + tokenSource.token + ); + + assert.ok(result); + if (result && result instanceof vscode.Hover) { + assert.ok(result.contents); + const markdown = result.contents[0] as vscode.MarkdownString; + assert.ok(markdown.value.includes('Azure Host')); + assert.ok(markdown.value.includes('containerapp')); + } + }); + + test('provides hover for "services" keyword', async () => { + const content = 'services:\n api:\n project: ./api'; + const document = await createTestDocument(content); + const position = new vscode.Position(0, 2); // On "services" + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideHover( + document, + position, + tokenSource.token + ); + + assert.ok(result); + if (result && result instanceof vscode.Hover) { + const markdown = result.contents[0] as vscode.MarkdownString; + assert.ok(markdown.value.includes('Services')); + assert.ok(markdown.value.includes('deployable component')); + } + }); + + test('provides hover for "project" keyword', async () => { + const content = 'services:\n api:\n project: ./api'; + const document = await createTestDocument(content); + const position = new vscode.Position(2, 6); // On "project" + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideHover( + document, + position, + tokenSource.token + ); + + assert.ok(result); + if (result && result instanceof vscode.Hover) { + const markdown = result.contents[0] as vscode.MarkdownString; + assert.ok(markdown.value.includes('Project Path')); + assert.ok(markdown.value.includes('Relative path')); + } + }); + + test('provides hover for "hooks" keyword', async () => { + const content = 'hooks:\n postdeploy:\n run: echo done'; + const document = await createTestDocument(content); + const position = new vscode.Position(0, 2); // On "hooks" + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideHover( + document, + position, + tokenSource.token + ); + + assert.ok(result); + if (result && result instanceof vscode.Hover) { + const markdown = result.contents[0] as vscode.MarkdownString; + assert.ok(markdown.value.includes('Lifecycle Hooks')); + assert.ok(markdown.value.includes('deployment lifecycle')); + } + }); + + test('returns null for unknown keyword', async () => { + const content = 'unknownkeyword: value'; + const document = await createTestDocument(content); + const position = new vscode.Position(0, 2); + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideHover( + document, + position, + tokenSource.token + ); + + assert.isNull(result); + }); + + test('hover includes example code', async () => { + const content = 'language: python'; + const document = await createTestDocument(content); + const position = new vscode.Position(0, 2); // On "language" + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideHover( + document, + position, + tokenSource.token + ); + + assert.ok(result); + if (result && result instanceof vscode.Hover) { + const markdown = result.contents[0] as vscode.MarkdownString; + assert.ok(markdown.value.includes('Example')); + assert.ok(markdown.value.includes('```yaml')); + } + }); + + test('hover includes documentation link', async () => { + const content = 'name: myapp'; + const document = await createTestDocument(content); + const position = new vscode.Position(0, 2); // On "name" + const tokenSource = new vscode.CancellationTokenSource(); + + const result = provider.provideHover( + document, + position, + tokenSource.token + ); + + assert.ok(result); + if (result && result instanceof vscode.Hover) { + const markdown = result.contents[0] as vscode.MarkdownString; + assert.ok(markdown.value.includes('View Documentation')); + assert.ok(markdown.value.includes('learn.microsoft.com')); + } + }); + }); + + async function createTestDocument(content: string): Promise { + const uri = vscode.Uri.parse('untitled:test-azure.yaml'); + const doc = await vscode.workspace.openTextDocument(uri); + const edit = new vscode.WorkspaceEdit(); + edit.insert(uri, new vscode.Position(0, 0), content); + await vscode.workspace.applyEdit(edit); + return doc; + } +}); diff --git a/ext/vscode/src/test/suite/unit/environmentsTreeDataProvider.test.ts b/ext/vscode/src/test/suite/unit/environmentsTreeDataProvider.test.ts new file mode 100644 index 00000000000..a74f0c59930 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/environmentsTreeDataProvider.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import { EnvironmentsTreeDataProvider, EnvironmentTreeItem, EnvironmentItem, EnvironmentVariableItem } from '../../../views/environments/EnvironmentsTreeDataProvider'; +import { WorkspaceAzureDevApplicationProvider } from '../../../services/AzureDevApplicationProvider'; +import { WorkspaceAzureDevEnvListProvider } from '../../../services/AzureDevEnvListProvider'; +import { WorkspaceAzureDevEnvValuesProvider } from '../../../services/AzureDevEnvValuesProvider'; + +suite('EnvironmentsTreeDataProvider', () => { + let provider: EnvironmentsTreeDataProvider; + let sandbox: sinon.SinonSandbox; + let appProviderStub: sinon.SinonStubbedInstance; + let envListProviderStub: sinon.SinonStubbedInstance; + let envValuesProviderStub: sinon.SinonStubbedInstance; + + setup(() => { + sandbox = sinon.createSandbox(); + provider = new EnvironmentsTreeDataProvider(); + + // Stub the providers + appProviderStub = sandbox.stub(WorkspaceAzureDevApplicationProvider.prototype); + envListProviderStub = sandbox.stub(WorkspaceAzureDevEnvListProvider.prototype); + envValuesProviderStub = sandbox.stub(WorkspaceAzureDevEnvValuesProvider.prototype); + }); + + teardown(() => { + provider.dispose(); + sandbox.restore(); + }); + + suite('getChildren', () => { + test('returns empty array when no applications are found', async () => { + appProviderStub.getApplications.resolves([]); + + const children = await provider.getChildren(); + + assert.strictEqual(children.length, 0); + }); + + test('returns environment items when applications exist', async () => { + const mockConfigPath = vscode.Uri.file('/test/azure.yaml'); + const mockWorkspaceFolder = { uri: vscode.Uri.file('/test'), name: 'test', index: 0 }; + appProviderStub.getApplications.resolves([ + { + configurationPath: mockConfigPath, + configurationFolder: '/test', + workspaceFolder: mockWorkspaceFolder as vscode.WorkspaceFolder + } + ]); + + envListProviderStub.getEnvListResults.resolves([ + { Name: 'dev', IsDefault: true, DotEnvPath: '.azure/dev/.env' }, + { Name: 'prod', IsDefault: false, DotEnvPath: '.azure/prod/.env' } + ]); + + const children = await provider.getChildren(); + + assert.strictEqual(children.length, 2); + assert.strictEqual(children[0].label, 'dev'); + assert.strictEqual(children[0].type, 'Environment'); + assert.strictEqual(children[0].description, '(Current)'); + assert.strictEqual(children[1].label, 'prod'); + }); + + test('marks default environment with appropriate icon and description', async () => { + const mockConfigPath = vscode.Uri.file('/test/azure.yaml'); + const mockWorkspaceFolder = { uri: vscode.Uri.file('/test'), name: 'test', index: 0 }; + appProviderStub.getApplications.resolves([ + { + configurationPath: mockConfigPath, + configurationFolder: '/test', + workspaceFolder: mockWorkspaceFolder as vscode.WorkspaceFolder + } + ]); + + envListProviderStub.getEnvListResults.resolves([ + { Name: 'dev', IsDefault: true, DotEnvPath: '.azure/dev/.env' } + ]); + + const children = await provider.getChildren(); + + assert.strictEqual(children.length, 1); + assert.strictEqual(children[0].description, '(Current)'); + assert.ok(children[0].contextValue?.includes('default')); + }); + + test('returns environment details when environment node is expanded', async () => { + const mockEnvItem: EnvironmentItem = { + name: 'dev', + isDefault: true, + configurationFile: vscode.Uri.file('/test/azure.yaml') + }; + + const envTreeItem = new EnvironmentTreeItem( + 'Environment', + 'dev', + vscode.TreeItemCollapsibleState.Collapsed, + mockEnvItem + ); + + const children = await provider.getChildren(envTreeItem); + + assert.ok(children.length > 0); + assert.strictEqual(children[0].label, 'Environment Variables'); + assert.strictEqual(children[0].type, 'Group'); + }); + + test('returns environment variables when variables group is expanded', async () => { + const mockEnvItem: EnvironmentItem = { + name: 'dev', + isDefault: true, + configurationFile: vscode.Uri.file('/test/azure.yaml') + }; + + const variablesGroup = new EnvironmentTreeItem( + 'Group', + 'Environment Variables', + vscode.TreeItemCollapsibleState.Collapsed, + mockEnvItem + ); + + envValuesProviderStub.getEnvValues.resolves({ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'AZURE_SUBSCRIPTION_ID': 'test-sub-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'AZURE_LOCATION': 'eastus' + }); + + const children = await provider.getChildren(variablesGroup); + + assert.strictEqual(children.length, 2); + assert.strictEqual(children[0].type, 'Variable'); + assert.ok(typeof children[0].label === 'string' && children[0].label.includes('Hidden value')); + }); + }); + + suite('toggleVisibility', () => { + test('toggles environment variable visibility from hidden to visible', async () => { + const mockEnvVarItem: EnvironmentVariableItem = { + name: 'dev', + isDefault: true, + configurationFile: vscode.Uri.file('/test/azure.yaml'), + key: 'AZURE_SUBSCRIPTION_ID', + value: 'test-sub-id' + }; + + const varTreeItem = new EnvironmentTreeItem( + 'Variable', + 'AZURE_SUBSCRIPTION_ID=Hidden value. Click to view.', + vscode.TreeItemCollapsibleState.None, + mockEnvVarItem + ); + + provider.toggleVisibility(varTreeItem); + + // After toggling, getTreeItem should return a new tree item with visible value + const updatedTreeItem = provider.getTreeItem(varTreeItem); + assert.ok(typeof updatedTreeItem.label === 'string' && updatedTreeItem.label.includes('test-sub-id')); + assert.ok(typeof updatedTreeItem.tooltip === 'string' && updatedTreeItem.tooltip.includes('test-sub-id')); + }); + + test('toggles environment variable visibility from visible to hidden', async () => { + const mockEnvVarItem: EnvironmentVariableItem = { + name: 'dev', + isDefault: true, + configurationFile: vscode.Uri.file('/test/azure.yaml'), + key: 'AZURE_SUBSCRIPTION_ID', + value: 'test-sub-id' + }; + + const varTreeItem = new EnvironmentTreeItem( + 'Variable', + 'AZURE_SUBSCRIPTION_ID=test-sub-id', + vscode.TreeItemCollapsibleState.None, + mockEnvVarItem + ); + + // First toggle to visible + provider.toggleVisibility(varTreeItem); + // Second toggle to hidden + provider.toggleVisibility(varTreeItem); + + // After toggling back, getTreeItem should return a new tree item with hidden value + const updatedTreeItem = provider.getTreeItem(varTreeItem); + assert.ok(typeof updatedTreeItem.label === 'string' && updatedTreeItem.label.includes('Hidden value')); + assert.ok(typeof updatedTreeItem.tooltip === 'string' && updatedTreeItem.tooltip.includes('Click to view value')); + }); + + test('does not toggle visibility for non-variable items', async () => { + const mockEnvItem: EnvironmentItem = { + name: 'dev', + isDefault: true, + configurationFile: vscode.Uri.file('/test/azure.yaml') + }; + + const envTreeItem = new EnvironmentTreeItem( + 'Environment', + 'dev', + vscode.TreeItemCollapsibleState.Collapsed, + mockEnvItem + ); + + const originalLabel = envTreeItem.label; + provider.toggleVisibility(envTreeItem); + + assert.strictEqual(envTreeItem.label, originalLabel); + }); + }); + + suite('refresh', () => { + test('fires onDidChangeTreeData event when refresh is called', (done) => { + provider.onDidChangeTreeData(() => { + done(); + }); + + provider.refresh(); + }); + }); + + suite('getTreeItem', () => { + test('returns the same tree item passed in', () => { + const mockEnvItem: EnvironmentItem = { + name: 'dev', + isDefault: true, + configurationFile: vscode.Uri.file('/test/azure.yaml') + }; + + const treeItem = new EnvironmentTreeItem( + 'Environment', + 'dev', + vscode.TreeItemCollapsibleState.Collapsed, + mockEnvItem + ); + + const result = provider.getTreeItem(treeItem); + + assert.strictEqual(result, treeItem); + }); + }); +}); diff --git a/ext/vscode/src/test/suite/unit/extensionsTreeDataProvider.test.ts b/ext/vscode/src/test/suite/unit/extensionsTreeDataProvider.test.ts new file mode 100644 index 00000000000..d89af5bccc4 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/extensionsTreeDataProvider.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { ExtensionsTreeDataProvider, ExtensionTreeItem } from '../../../views/extensions/ExtensionsTreeDataProvider'; +import { WorkspaceAzureDevExtensionProvider, AzureDevExtension } from '../../../services/AzureDevExtensionProvider'; + +suite('ExtensionsTreeDataProvider', () => { + let provider: ExtensionsTreeDataProvider; + let sandbox: sinon.SinonSandbox; + let extensionProviderStub: sinon.SinonStubbedInstance; + + setup(() => { + sandbox = sinon.createSandbox(); + provider = new ExtensionsTreeDataProvider(); + + // Stub the extension provider + extensionProviderStub = sandbox.stub(WorkspaceAzureDevExtensionProvider.prototype); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite('getChildren', () => { + test('returns empty array when no extensions are installed', async () => { + extensionProviderStub.getExtensionListResults.resolves([]); + + const children = await provider.getChildren(); + + assert.strictEqual(children.length, 0); + }); + + test('returns extension items when extensions are installed', async () => { + const mockExtensions: AzureDevExtension[] = [ + { id: 'test-ext-1', name: 'test-extension-1', version: '1.0.0' }, + { id: 'test-ext-2', name: 'test-extension-2', version: '2.1.3' } + ]; + + extensionProviderStub.getExtensionListResults.resolves(mockExtensions); + + const children = await provider.getChildren(); + + assert.strictEqual(children.length, 2); + assert.strictEqual(children[0].extension.name, 'test-extension-1'); + assert.strictEqual(children[0].extension.version, '1.0.0'); + assert.strictEqual(children[0].description, '1.0.0'); + assert.strictEqual(children[1].extension.name, 'test-extension-2'); + assert.strictEqual(children[1].extension.version, '2.1.3'); + }); + + test('returns empty array for children of extension items', async () => { + const mockExtension: AzureDevExtension = { + id: 'test-ext', + name: 'test-extension', + version: '1.0.0' + }; + + const extensionTreeItem = new ExtensionTreeItem(mockExtension); + + const children = await provider.getChildren(extensionTreeItem); + + assert.strictEqual(children.length, 0); + }); + }); + + suite('getTreeItem', () => { + test('returns the same tree item passed in', () => { + const mockExtension: AzureDevExtension = { + id: 'test-ext', + name: 'test-extension', + version: '1.0.0' + }; + + const treeItem = new ExtensionTreeItem(mockExtension); + const result = provider.getTreeItem(treeItem); + + assert.strictEqual(result, treeItem); + }); + }); + + suite('refresh', () => { + test('fires onDidChangeTreeData event when refresh is called', (done) => { + provider.onDidChangeTreeData(() => { + done(); + }); + + provider.refresh(); + }); + }); + + suite('ExtensionTreeItem', () => { + test('creates tree item with correct properties', () => { + const mockExtension: AzureDevExtension = { + id: 'my-ext', + name: 'my-extension', + version: '3.2.1' + }; + + const treeItem = new ExtensionTreeItem(mockExtension); + + assert.strictEqual(treeItem.label, 'my-extension'); + assert.strictEqual(treeItem.description, '3.2.1'); + assert.strictEqual(treeItem.contextValue, 'ms-azuretools.azure-dev.views.extensions.extension'); + assert.strictEqual(treeItem.collapsibleState, 0); // None + }); + }); +}); diff --git a/ext/vscode/src/test/suite/unit/openInPortalStep.test.ts b/ext/vscode/src/test/suite/unit/openInPortalStep.test.ts new file mode 100644 index 00000000000..5c3195cf965 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/openInPortalStep.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { OpenInPortalStep } from '../../../commands/azureWorkspace/wizard/OpenInPortalStep'; +import { RevealResourceWizardContext } from '../../../commands/azureWorkspace/wizard/PickResourceStep'; + +suite('OpenInPortalStep', () => { + let step: OpenInPortalStep; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + step = new OpenInPortalStep(); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite('shouldExecute', () => { + test('returns true when azureResourceId is present', () => { + const context: Partial = { + azureResourceId: '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app' + }; + + const result = step.shouldExecute(context as RevealResourceWizardContext); + + assert.strictEqual(result, true); + }); + + test('returns false when azureResourceId is missing', () => { + const context: Partial = {}; + + const result = step.shouldExecute(context as RevealResourceWizardContext); + + assert.strictEqual(result, false); + }); + + test('returns false when azureResourceId is empty string', () => { + const context: Partial = { + azureResourceId: '' + }; + + const result = step.shouldExecute(context as RevealResourceWizardContext); + + assert.strictEqual(result, false); + }); + }); + + suite('execute', () => { + test('constructs correct portal URL for Web App resource', async () => { + const azureResourceId = '/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/my-rg/providers/Microsoft.Web/sites/my-app'; + const context: Partial = { + azureResourceId + }; + + const openExternalStub = sandbox.stub(vscode.env, 'openExternal').resolves(true); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(openExternalStub.calledOnce); + const calledUri = openExternalStub.firstCall.args[0] as vscode.Uri; + const expectedUri = vscode.Uri.parse(`https://portal.azure.com/#@/resource${azureResourceId}`); + assert.strictEqual(calledUri.toString(), expectedUri.toString()); + }); + + test('constructs correct portal URL for Storage Account resource', async () => { + const azureResourceId = '/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/mystorageaccount'; + const context: Partial = { + azureResourceId + }; + + const openExternalStub = sandbox.stub(vscode.env, 'openExternal').resolves(true); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(openExternalStub.calledOnce); + const calledUri = openExternalStub.firstCall.args[0] as vscode.Uri; + const expectedUri = vscode.Uri.parse(`https://portal.azure.com/#@/resource${azureResourceId}`); + assert.strictEqual(calledUri.toString(), expectedUri.toString()); + }); + + test('constructs correct portal URL for Cosmos DB resource', async () => { + const azureResourceId = '/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/my-rg/providers/Microsoft.DocumentDB/databaseAccounts/mycosmosdb'; + const context: Partial = { + azureResourceId + }; + + const openExternalStub = sandbox.stub(vscode.env, 'openExternal').resolves(true); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(openExternalStub.calledOnce); + const calledUri = openExternalStub.firstCall.args[0] as vscode.Uri; + const expectedUri = vscode.Uri.parse(`https://portal.azure.com/#@/resource${azureResourceId}`); + assert.strictEqual(calledUri.toString(), expectedUri.toString()); + }); + + test('constructs correct portal URL for Resource Group', async () => { + const azureResourceId = '/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/my-rg'; + const context: Partial = { + azureResourceId + }; + + const openExternalStub = sandbox.stub(vscode.env, 'openExternal').resolves(true); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(openExternalStub.calledOnce); + const calledUri = openExternalStub.firstCall.args[0] as vscode.Uri; + const expectedUri = vscode.Uri.parse(`https://portal.azure.com/#@/resource${azureResourceId}`); + assert.strictEqual(calledUri.toString(), expectedUri.toString()); + }); + + test('constructs correct portal URL for Container Apps resource', async () => { + const azureResourceId = '/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/my-rg/providers/Microsoft.App/containerApps/my-container-app'; + const context: Partial = { + azureResourceId + }; + + const openExternalStub = sandbox.stub(vscode.env, 'openExternal').resolves(true); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(openExternalStub.calledOnce); + const calledUri = openExternalStub.firstCall.args[0] as vscode.Uri; + const expectedUri = vscode.Uri.parse(`https://portal.azure.com/#@/resource${azureResourceId}`); + assert.strictEqual(calledUri.toString(), expectedUri.toString()); + }); + + test('throws error when azureResourceId is missing', async () => { + const context: Partial = {}; + + await assert.rejects( + async () => await step.execute(context as RevealResourceWizardContext), + (error: Error) => { + return error.message.includes('azureResourceId'); + } + ); + }); + }); + + suite('priority', () => { + test('has correct priority value', () => { + assert.strictEqual(step.priority, 100); + }); + }); +}); diff --git a/ext/vscode/src/test/suite/unit/revealStep.test.ts b/ext/vscode/src/test/suite/unit/revealStep.test.ts new file mode 100644 index 00000000000..9146c253726 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/revealStep.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { AzureResourcesExtensionApi } from '@microsoft/vscode-azureresources-api'; +import { RevealStep } from '../../../commands/azureWorkspace/wizard/RevealStep'; +import { RevealResourceWizardContext } from '../../../commands/azureWorkspace/wizard/PickResourceStep'; +import * as getAzureResourceExtensionApiModule from '../../../utils/getAzureResourceExtensionApi'; +import ext from '../../../ext'; + +suite('RevealStep', () => { + let step: RevealStep; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + step = new RevealStep(); + + // Mock ext.outputChannel + ext.outputChannel = { + appendLog: sandbox.stub() + } as Partial as typeof ext.outputChannel; + }); + + teardown(() => { + sandbox.restore(); + }); + + suite('shouldExecute', () => { + test('returns true when azureResourceId is present', () => { + const context: Partial = { + azureResourceId: '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app' + }; + + const result = step.shouldExecute(context as RevealResourceWizardContext); + + assert.strictEqual(result, true); + }); + + test('returns false when azureResourceId is missing', () => { + const context: Partial = {}; + + const result = step.shouldExecute(context as RevealResourceWizardContext); + + assert.strictEqual(result, false); + }); + + test('returns false when azureResourceId is empty string', () => { + const context: Partial = { + azureResourceId: '' + }; + + const result = step.shouldExecute(context as RevealResourceWizardContext); + + assert.strictEqual(result, false); + }); + }); + + suite('execute', () => { + let executeCommandStub: sinon.SinonStub; + let getExtensionStub: sinon.SinonStub; + let getAzureResourceExtensionApiStub: sinon.SinonStub; + + setup(() => { + executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand'); + getExtensionStub = sandbox.stub(vscode.extensions, 'getExtension'); + + // Mock the Azure Resource Extension API + const mockApi: Partial = { + resources: { + revealAzureResource: sandbox.stub().resolves(true) + } as unknown as AzureResourcesExtensionApi['resources'] + }; + getAzureResourceExtensionApiStub = sandbox.stub(getAzureResourceExtensionApiModule, 'getAzureResourceExtensionApi').resolves(mockApi as AzureResourcesExtensionApi); + }); + + test('focuses Azure Resources view', async () => { + const context: Partial = { + azureResourceId: '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app' + }; + + executeCommandStub.resolves(); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(executeCommandStub.calledWith('azureResourceGroups.focus')); + }); + + test('activates appropriate extension for Microsoft.Web provider', async () => { + const context: Partial = { + azureResourceId: '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app' + }; + + const mockExtension = { + isActive: false, + activate: sandbox.stub().resolves() + }; + + getExtensionStub.withArgs('ms-azuretools.vscode-azurefunctions').returns(mockExtension); + executeCommandStub.resolves(); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(mockExtension.activate.calledOnce); + }); + + test('activates appropriate extension for Microsoft.Storage provider', async () => { + const context: Partial = { + azureResourceId: '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-storage' + }; + + const mockExtension = { + isActive: false, + activate: sandbox.stub().resolves() + }; + + getExtensionStub.withArgs('ms-azuretools.vscode-azurestorage').returns(mockExtension); + executeCommandStub.resolves(); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(mockExtension.activate.calledOnce); + }); + + test('activates appropriate extension for Microsoft.DocumentDB provider', async () => { + const context: Partial = { + azureResourceId: '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-cosmos' + }; + + const mockExtension = { + isActive: false, + activate: sandbox.stub().resolves() + }; + + getExtensionStub.withArgs('ms-azuretools.azure-cosmos').returns(mockExtension); + executeCommandStub.resolves(); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(mockExtension.activate.calledOnce); + }); + + test('activates appropriate extension for Microsoft.App provider', async () => { + const context: Partial = { + azureResourceId: '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.App/containerApps/test-app' + }; + + const mockExtension = { + isActive: false, + activate: sandbox.stub().resolves() + }; + + getExtensionStub.withArgs('ms-azuretools.vscode-azurecontainerapps').returns(mockExtension); + executeCommandStub.resolves(); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(mockExtension.activate.calledOnce); + }); + + test('does not activate extension if already active', async () => { + const context: Partial = { + azureResourceId: '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app' + }; + + const mockExtension = { + isActive: true, + activate: sandbox.stub().resolves() + }; + + getExtensionStub.withArgs('ms-azuretools.vscode-azurefunctions').returns(mockExtension); + executeCommandStub.resolves(); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(mockExtension.activate.notCalled); + }); + + test('attempts to refresh Azure Resources tree', async () => { + const context: Partial = { + azureResourceId: '/subscriptions/test-sub-id/resourceGroups/test-rg' + }; + + executeCommandStub.resolves(); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(executeCommandStub.calledWith('azureResourceGroups.refresh')); + }); + + test('calls revealAzureResource with correct resource ID and options', async () => { + const azureResourceId = '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app'; + const context: Partial = { + azureResourceId + }; + + const mockRevealAzureResource = sandbox.stub().resolves(true); + const mockApi: Partial = { + resources: { + revealAzureResource: mockRevealAzureResource + } as unknown as AzureResourcesExtensionApi['resources'] + }; + getAzureResourceExtensionApiStub.resolves(mockApi as AzureResourcesExtensionApi); + executeCommandStub.resolves(); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(mockRevealAzureResource.called); + assert.ok(mockRevealAzureResource.calledWith(azureResourceId, { select: true, focus: true, expand: true })); + }); + + test('attempts to reveal resource group first when resource has RG in path', async () => { + const azureResourceId = '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app'; + const context: Partial = { + azureResourceId + }; + + const mockRevealAzureResource = sandbox.stub().resolves(true); + const mockApi: Partial = { + resources: { + revealAzureResource: mockRevealAzureResource + } as unknown as AzureResourcesExtensionApi['resources'] + }; + getAzureResourceExtensionApiStub.resolves(mockApi as AzureResourcesExtensionApi); + executeCommandStub.resolves(); + + await step.execute(context as RevealResourceWizardContext); + + // Should be called twice: once for RG, once for the resource + assert.ok(mockRevealAzureResource.callCount >= 2); + + // First call should be for the resource group + const rgResourceId = '/subscriptions/test-sub-id/resourceGroups/test-rg'; + assert.ok(mockRevealAzureResource.calledWith(rgResourceId, { select: false, focus: false, expand: true })); + }); + + test('shows error message when reveal fails', async () => { + const context: Partial = { + azureResourceId: '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app' + }; + + const mockApi: Partial = { + resources: { + revealAzureResource: sandbox.stub().rejects(new Error('Reveal failed')) + } as unknown as AzureResourcesExtensionApi['resources'] + }; + getAzureResourceExtensionApiStub.resolves(mockApi as AzureResourcesExtensionApi); + executeCommandStub.resolves(); + + const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + + await assert.rejects( + async () => await step.execute(context as RevealResourceWizardContext), + (error: Error) => error.message === 'Reveal failed' + ); + + assert.ok(showErrorMessageStub.called); + }); + + test('shows info message with Copy and Portal options when reveal returns undefined', async () => { + const azureResourceId = '/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app'; + const context: Partial = { + azureResourceId + }; + + const mockApi: Partial = { + resources: { + revealAzureResource: sandbox.stub().resolves(undefined) + } as unknown as AzureResourcesExtensionApi['resources'] + }; + getAzureResourceExtensionApiStub.resolves(mockApi as AzureResourcesExtensionApi); + // Make executeCommand fail for the alternative reveal command but succeed for others + executeCommandStub.callsFake((command: string) => { + if (command === 'azureResourceGroups.revealResource') { + return Promise.reject(new Error('Alternative reveal failed')); + } + return Promise.resolve(); + }); + + const showInfoMessageStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(); + + await step.execute(context as RevealResourceWizardContext); + + assert.ok(showInfoMessageStub.called); + assert.ok(showInfoMessageStub.firstCall.args[0].includes('Unable to automatically reveal resource')); + }); + }); + + suite('priority', () => { + test('has correct priority value', () => { + assert.strictEqual(step.priority, 100); + }); + }); +}); diff --git a/ext/vscode/src/test/suite/unit/templateToolsTreeDataProvider.test.ts b/ext/vscode/src/test/suite/unit/templateToolsTreeDataProvider.test.ts new file mode 100644 index 00000000000..70ecdc4a0fe --- /dev/null +++ b/ext/vscode/src/test/suite/unit/templateToolsTreeDataProvider.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import { TemplateToolsTreeDataProvider } from '../../../views/templateTools/TemplateToolsTreeDataProvider'; + +suite('TemplateToolsTreeDataProvider', () => { + let provider: TemplateToolsTreeDataProvider; + let workspaceFindFilesStub: sinon.SinonStub; + + setup(() => { + provider = new TemplateToolsTreeDataProvider(); + workspaceFindFilesStub = sinon.stub(vscode.workspace, 'findFiles'); + }); + + teardown(() => { + provider.dispose(); + sinon.restore(); + }); + + test('getChildren returns root items when no element provided', async () => { + // Simulate no azure.yaml in workspace + workspaceFindFilesStub.resolves([]); + + const children = await provider.getChildren(); + + assert.ok(Array.isArray(children), 'Should return an array'); + assert.ok(children.length > 0, 'Should have root items'); + + // Should have Quick Start section when no azure.yaml + const hasQuickStart = children.some(child => child.label === 'Quick Start'); + assert.ok(hasQuickStart, 'Should have Quick Start section when no azure.yaml'); + }); + + test('getChildren does not show Quick Start when azure.yaml exists', async () => { + // Simulate azure.yaml exists in workspace + const mockUri = vscode.Uri.file('/test/azure.yaml'); + workspaceFindFilesStub.resolves([mockUri]); + + const children = await provider.getChildren(); + + assert.ok(Array.isArray(children), 'Should return an array'); + + // Should NOT have Quick Start section when azure.yaml exists + const hasQuickStart = children.some(child => child.label === 'Quick Start'); + assert.ok(!hasQuickStart, 'Should not have Quick Start section when azure.yaml exists'); + }); + + test('getTreeItem returns the same tree item', async () => { + workspaceFindFilesStub.resolves([]); + const children = await provider.getChildren(); + + if (children.length > 0) { + const treeItem = provider.getTreeItem(children[0]); + assert.strictEqual(treeItem, children[0], 'Should return the same tree item'); + } + }); + + test('refresh fires onDidChangeTreeData event', (done) => { + workspaceFindFilesStub.resolves([]); + + provider.onDidChangeTreeData(() => { + done(); + }); + + provider.refresh(); + }); + + test('root items include category group', async () => { + workspaceFindFilesStub.resolves([]); + + const children = await provider.getChildren(); + + const hasCategoryGroup = children.some(child => child.label === 'Browse by Category'); + assert.ok(hasCategoryGroup, 'Should have category group'); + }); + + test('root items include AI templates', async () => { + workspaceFindFilesStub.resolves([]); + + const children = await provider.getChildren(); + + const hasAITemplates = children.some(child => child.label === 'AI Templates'); + assert.ok(hasAITemplates, 'Should have AI templates section'); + }); + + test('root items include search', async () => { + workspaceFindFilesStub.resolves([]); + + const children = await provider.getChildren(); + + const hasSearch = children.some(child => child.label === 'Search Templates...'); + assert.ok(hasSearch, 'Should have search option'); + }); + + test('Quick Start items have correct properties', async () => { + workspaceFindFilesStub.resolves([]); + + const rootChildren = await provider.getChildren(); + const quickStartGroup = rootChildren.find(child => child.label === 'Quick Start'); + + assert.ok(quickStartGroup, 'Should have Quick Start group'); + + if (quickStartGroup) { + const quickStartItems = await provider.getChildren(quickStartGroup); + + assert.ok(quickStartItems.length >= 3, 'Should have at least 3 Quick Start items'); + + const initFromCode = quickStartItems.find(item => + (item.label as string).includes('Initialize from Current Code') + ); + assert.ok(initFromCode, 'Should have Initialize from Code option'); + assert.ok(initFromCode.command, 'Should have command'); + + const initMinimal = quickStartItems.find(item => + (item.label as string).includes('Create Minimal Project') + ); + assert.ok(initMinimal, 'Should have Create Minimal option'); + assert.ok(initMinimal.command, 'Should have command'); + + const browseGallery = quickStartItems.find(item => + (item.label as string).includes('Browse Template Gallery') + ); + assert.ok(browseGallery, 'Should have Browse Gallery option'); + assert.ok(browseGallery.command, 'Should have command'); + } + }); + + test('search item has command configured', async () => { + workspaceFindFilesStub.resolves([]); + + const children = await provider.getChildren(); + const searchItem = children.find(child => child.label === 'Search Templates...'); + + assert.ok(searchItem, 'Should have search item'); + assert.ok(searchItem.command, 'Search item should have command'); + assert.strictEqual( + searchItem.command.command, + 'azure-dev.views.templateTools.search', + 'Should have correct command ID' + ); + }); + + test('template item opens README on click', async () => { + const provider = new TemplateToolsTreeDataProvider(); + workspaceFindFilesStub.resolves([]); + + // Get AI templates section children + const rootItems = await provider.getChildren(); + const aiSection = rootItems.find((item: vscode.TreeItem) => item.contextValue === 'aiTemplates'); + const templateItems = await provider.getChildren(aiSection); + const templateItem = templateItems[0] as vscode.TreeItem & { command?: vscode.Command }; + + assert.strictEqual( + templateItem.command?.command, + 'azure-dev.views.templateTools.openReadme', + 'Should open README on click' + ); + assert.ok( + templateItem.command?.arguments, + 'Should have command arguments' + ); + }); + + test('template item has correct context value for inline actions', async () => { + const provider = new TemplateToolsTreeDataProvider(); + workspaceFindFilesStub.resolves([]); + + // Get AI templates section children + const rootItems = await provider.getChildren(); + const aiSection = rootItems.find((item: vscode.TreeItem) => item.contextValue === 'aiTemplates'); + const templateItems = await provider.getChildren(aiSection); + const templateItem = templateItems[0] as vscode.TreeItem; + + assert.strictEqual( + templateItem.contextValue, + 'template', + 'Should have template context value for inline menu actions' + ); + }); +}); diff --git a/ext/vscode/src/utils/azureDevCli.ts b/ext/vscode/src/utils/azureDevCli.ts index 3129c773756..94ddd116213 100644 --- a/ext/vscode/src/utils/azureDevCli.ts +++ b/ext/vscode/src/utils/azureDevCli.ts @@ -227,6 +227,9 @@ function azdNotInstalledUserChoices(): AzExtErrorButton[] { } // isAzdCommand returns true if this is the command to run azd. -export function isAzdCommand(command: string): boolean { +export function isAzdCommand(command: string | undefined): boolean { + if (!command) { + return false; + } return command === getAzDevInvocation() || command.startsWith(`${getAzDevInvocation()} `); } diff --git a/ext/vscode/src/utils/isTreeViewModel.ts b/ext/vscode/src/utils/isTreeViewModel.ts index b766c6dc5fb..ddd653f5c0b 100644 --- a/ext/vscode/src/utils/isTreeViewModel.ts +++ b/ext/vscode/src/utils/isTreeViewModel.ts @@ -3,9 +3,14 @@ import { isWrapper, Wrapper } from '@microsoft/vscode-azureresources-api'; import * as vscode from 'vscode'; +import { AzureDevCliModel } from '../views/workspace/AzureDevCliModel'; export type TreeViewModel = Wrapper; export function isTreeViewModel(selectedItem: vscode.Uri | TreeViewModel | undefined | unknown): selectedItem is TreeViewModel { return isWrapper(selectedItem); } + +export function isAzureDevCliModel(item: unknown): item is AzureDevCliModel { + return !!item && typeof item === 'object' && 'context' in item && !!(item as AzureDevCliModel).context && 'configurationFile' in (item as AzureDevCliModel).context; +} diff --git a/ext/vscode/src/views/environments/EnvironmentsTreeDataProvider.ts b/ext/vscode/src/views/environments/EnvironmentsTreeDataProvider.ts new file mode 100644 index 00000000000..9330e04a475 --- /dev/null +++ b/ext/vscode/src/views/environments/EnvironmentsTreeDataProvider.ts @@ -0,0 +1,216 @@ +import * as vscode from 'vscode'; +import { callWithTelemetryAndErrorHandling, IActionContext } from '@microsoft/vscode-azext-utils'; +import { TelemetryId } from '../../telemetry/telemetryId'; +import { WorkspaceAzureDevApplicationProvider } from '../../services/AzureDevApplicationProvider'; +import { WorkspaceAzureDevEnvListProvider } from '../../services/AzureDevEnvListProvider'; +import { WorkspaceAzureDevEnvValuesProvider } from '../../services/AzureDevEnvValuesProvider'; + +export interface EnvironmentItem { + name: string; + isDefault: boolean; + dotEnvPath?: string; + configurationFile: vscode.Uri; +} + +export interface EnvironmentVariableItem extends EnvironmentItem { + key: string; + value: string; +} + +type TreeItemType = 'Environment' | 'Group' | 'Detail' | 'Variable'; + +export class EnvironmentTreeItem extends vscode.TreeItem { + constructor( + public readonly type: TreeItemType, + label: string, + collapsibleState: vscode.TreeItemCollapsibleState, + public readonly data?: EnvironmentItem | EnvironmentVariableItem + ) { + super(label, collapsibleState); + } +} + +export class EnvironmentsTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + private readonly applicationProvider = new WorkspaceAzureDevApplicationProvider(); + private readonly envListProvider = new WorkspaceAzureDevEnvListProvider(); + private readonly envValuesProvider = new WorkspaceAzureDevEnvValuesProvider(); + private readonly configFileWatcher: vscode.FileSystemWatcher; + private readonly envDirWatcher: vscode.FileSystemWatcher; + private readonly visibleEnvVars = new Set(); + + constructor() { + this.configFileWatcher = vscode.workspace.createFileSystemWatcher( + '**/azure.{yml,yaml}', + false, false, false + ); + + this.envDirWatcher = vscode.workspace.createFileSystemWatcher( + '**/.azure/**', + false, false, false + ); + + const onFileChange = () => { + this.refresh(); + }; + + this.configFileWatcher.onDidCreate(onFileChange); + this.configFileWatcher.onDidChange(onFileChange); + this.configFileWatcher.onDidDelete(onFileChange); + + this.envDirWatcher.onDidCreate(onFileChange); + this.envDirWatcher.onDidChange(onFileChange); + this.envDirWatcher.onDidDelete(onFileChange); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + toggleVisibility(item: EnvironmentTreeItem): void { + if (item.type === 'Variable' && item.data) { + const data = item.data as EnvironmentVariableItem; + const id = `${data.name}/${data.key}`; + if (this.visibleEnvVars.has(id)) { + this.visibleEnvVars.delete(id); + } else { + this.visibleEnvVars.add(id); + } + + // Signal that this item's representation has changed; getTreeItem will + // recreate the TreeItem with the appropriate label and tooltip. + this._onDidChangeTreeData.fire(item); + } + } + + getTreeItem(element: EnvironmentTreeItem): vscode.TreeItem { + if (element.type === 'Variable' && element.data) { + const data = element.data as EnvironmentVariableItem; + const id = `${data.name}/${data.key}`; + const isVisible = this.visibleEnvVars.has(id); + + const label = isVisible + ? `${data.key}=${data.value}` + : `${data.key}=Hidden value. Click to view.`; + const tooltip = isVisible + ? `${data.key}=${data.value}` + : 'Click to view value'; + + const treeItem = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); + treeItem.tooltip = tooltip; + treeItem.contextValue = element.contextValue; + treeItem.command = element.command; + treeItem.iconPath = element.iconPath; + + return treeItem; + } + return element; + } + + async getChildren(element?: EnvironmentTreeItem): Promise { + return await callWithTelemetryAndErrorHandling(TelemetryId.WorkspaceViewEnvironmentResolve, async (context) => { + if (!element) { + return this.getEnvironments(context); + } + + if (element.type === 'Environment') { + return this.getEnvironmentDetails(context, element.data as EnvironmentItem); + } + + if (element.type === 'Group' && element.label === 'Environment Variables') { + return this.getEnvironmentVariables(context, element.data as EnvironmentItem); + } + + return []; + }) ?? []; + } + + private async getEnvironments(context: IActionContext): Promise { + const applications = await this.applicationProvider.getApplications(); + if (applications.length === 0) { + return []; + } + + // Assuming single project for now as per requirement + const app = applications[0]; + const envs = await this.envListProvider.getEnvListResults(context, app.configurationPath); + + return envs.map(env => { + const item = new EnvironmentTreeItem( + 'Environment', + env.Name, + vscode.TreeItemCollapsibleState.Collapsed, + { + name: env.Name, + isDefault: env.IsDefault, + dotEnvPath: env.DotEnvPath, + configurationFile: app.configurationPath + } as EnvironmentItem + ); + item.contextValue = 'ms-azuretools.azure-dev.views.environments.environment'; + + if (env.IsDefault) { + item.description = vscode.l10n.t('(Current)'); + item.contextValue += ';default'; + item.iconPath = new vscode.ThemeIcon('pass', new vscode.ThemeColor('testing.iconPassed')); + } else { + item.iconPath = new vscode.ThemeIcon('circle-large-outline'); + } + + return item; + }); + } + + private async getEnvironmentDetails(context: IActionContext, env: EnvironmentItem): Promise { + const items: EnvironmentTreeItem[] = []; + + // Properties Group + // For now, just listing properties directly or we could group them + // Let's add a group for Variables + const variablesGroup = new EnvironmentTreeItem( + 'Group', + 'Environment Variables', + vscode.TreeItemCollapsibleState.Collapsed, + env + ); + variablesGroup.iconPath = new vscode.ThemeIcon('symbol-variable'); + items.push(variablesGroup); + + return items; + } + + private async getEnvironmentVariables(context: IActionContext, env: EnvironmentItem): Promise { + const values = await this.envValuesProvider.getEnvValues(context, env.configurationFile, env.name); + + return Object.entries(values).map(([key, value]) => { + const id = `${env.name}/${key}`; + const isVisible = this.visibleEnvVars.has(id); + const label = isVisible ? `${key}=${value}` : `${key}=Hidden value. Click to view.`; + + const item = new EnvironmentTreeItem( + 'Variable', + label, + vscode.TreeItemCollapsibleState.None, + { ...env, key, value } as EnvironmentVariableItem + ); + + item.tooltip = isVisible ? `${key}=${value}` : 'Click to view value'; + item.iconPath = new vscode.ThemeIcon('key'); + item.command = { + command: 'azure-dev.views.environments.toggleVisibility', + title: 'Toggle Visibility', + arguments: [item] + }; + + return item; + }); + } + + dispose(): void { + this.configFileWatcher.dispose(); + this.envDirWatcher.dispose(); + this._onDidChangeTreeData.dispose(); + } +} diff --git a/ext/vscode/src/views/extensions/ExtensionsTreeDataProvider.ts b/ext/vscode/src/views/extensions/ExtensionsTreeDataProvider.ts new file mode 100644 index 00000000000..b8eb4dbd17b --- /dev/null +++ b/ext/vscode/src/views/extensions/ExtensionsTreeDataProvider.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as vscode from 'vscode'; +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; +import { TelemetryId } from '../../telemetry/telemetryId'; +import { WorkspaceAzureDevExtensionProvider, AzureDevExtension } from '../../services/AzureDevExtensionProvider'; + +export class ExtensionTreeItem extends vscode.TreeItem { + constructor( + public readonly extension: AzureDevExtension + ) { + super(extension.name, vscode.TreeItemCollapsibleState.None); + this.description = extension.version; + this.iconPath = new vscode.ThemeIcon('extensions'); + this.contextValue = 'ms-azuretools.azure-dev.views.extensions.extension'; + } +} + +export class ExtensionsTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + private readonly extensionProvider = new WorkspaceAzureDevExtensionProvider(); + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: ExtensionTreeItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: ExtensionTreeItem): Promise { + if (element) { + return []; + } + + return await callWithTelemetryAndErrorHandling(TelemetryId.WorkspaceViewExtensionResolve, async (context) => { + const extensions = await this.extensionProvider.getExtensionListResults(context); + return extensions.map(ext => new ExtensionTreeItem(ext)); + }) ?? []; + } +} diff --git a/ext/vscode/src/views/helpAndFeedback/HelpAndFeedbackTreeDataProvider.ts b/ext/vscode/src/views/helpAndFeedback/HelpAndFeedbackTreeDataProvider.ts new file mode 100644 index 00000000000..3792f8580b4 --- /dev/null +++ b/ext/vscode/src/views/helpAndFeedback/HelpAndFeedbackTreeDataProvider.ts @@ -0,0 +1,62 @@ +import * as vscode from 'vscode'; + +export class HelpAndFeedbackTreeDataProvider implements vscode.TreeDataProvider { + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + getChildren(element?: vscode.TreeItem): vscode.ProviderResult { + if (element) { + return []; + } + + const items: vscode.TreeItem[] = []; + + const documentation = new vscode.TreeItem('Documentation', vscode.TreeItemCollapsibleState.None); + documentation.iconPath = new vscode.ThemeIcon('book'); + documentation.command = { + command: 'vscode.open', + title: 'Open Documentation', + arguments: [vscode.Uri.parse('https://learn.microsoft.com/azure/developer/azure-developer-cli/')] + }; + items.push(documentation); + + const blogPosts = new vscode.TreeItem('AZD Blog Posts', vscode.TreeItemCollapsibleState.None); + blogPosts.iconPath = new vscode.ThemeIcon('library'); + blogPosts.command = { + command: 'vscode.open', + title: 'Open AZD Blog Posts', + arguments: [vscode.Uri.parse('https://devblogs.microsoft.com/azure-sdk/tag/azure-developer-cli/')] + }; + items.push(blogPosts); + + const getStarted = new vscode.TreeItem('Get Started', vscode.TreeItemCollapsibleState.None); + getStarted.iconPath = new vscode.ThemeIcon('rocket'); + getStarted.command = { + command: 'workbench.action.openWalkthrough', + title: 'Get Started', + arguments: ['ms-azuretools.azure-dev#azd.start'] + }; + items.push(getStarted); + + const whatsNew = new vscode.TreeItem("What's New", vscode.TreeItemCollapsibleState.None); + whatsNew.iconPath = new vscode.ThemeIcon('sparkle'); + whatsNew.command = { + command: 'vscode.open', + title: "What's New", + arguments: [vscode.Uri.parse('https://github.com/Azure/azure-dev/releases')] + }; + items.push(whatsNew); + + const reportIssues = new vscode.TreeItem('Report Issues on GitHub', vscode.TreeItemCollapsibleState.None); + reportIssues.iconPath = new vscode.ThemeIcon('github'); + reportIssues.command = { + command: 'vscode.open', + title: 'Report Issues', + arguments: [vscode.Uri.parse('https://github.com/Azure/azure-dev/issues')] + }; + items.push(reportIssues); + + return items; + } +} diff --git a/ext/vscode/src/views/myProject/MyProjectTreeDataProvider.ts b/ext/vscode/src/views/myProject/MyProjectTreeDataProvider.ts new file mode 100644 index 00000000000..3d6ecee7bf1 --- /dev/null +++ b/ext/vscode/src/views/myProject/MyProjectTreeDataProvider.ts @@ -0,0 +1,88 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { AzureDevCliModel } from '../workspace/AzureDevCliModel'; +import { AzureDevApplicationProvider, WorkspaceAzureDevApplicationProvider } from '../../services/AzureDevApplicationProvider'; +import { AzureDevCliApplication } from '../workspace/AzureDevCliApplication'; +import { WorkspaceAzureDevShowProvider } from '../../services/AzureDevShowProvider'; +import { WorkspaceAzureDevEnvListProvider } from '../../services/AzureDevEnvListProvider'; +import { WorkspaceAzureDevEnvValuesProvider } from '../../services/AzureDevEnvValuesProvider'; +import { WorkspaceResource } from '@microsoft/vscode-azureresources-api'; + +export class MyProjectTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + private readonly applicationProvider: AzureDevApplicationProvider; + private readonly showProvider = new WorkspaceAzureDevShowProvider(); + private readonly envListProvider = new WorkspaceAzureDevEnvListProvider(); + private readonly envValuesProvider = new WorkspaceAzureDevEnvValuesProvider(); + private readonly configFileWatcher: vscode.FileSystemWatcher; + + constructor() { + this.applicationProvider = new WorkspaceAzureDevApplicationProvider(); + + // Listen to azure.yaml file changes globally + this.configFileWatcher = vscode.workspace.createFileSystemWatcher( + '**/azure.{yml,yaml}', + false, false, false + ); + + const onFileChange = () => { + this.refresh(); + }; + + this.configFileWatcher.onDidCreate(onFileChange); + this.configFileWatcher.onDidChange(onFileChange); + this.configFileWatcher.onDidDelete(onFileChange); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: AzureDevCliModel): vscode.TreeItem | Thenable { + return element.getTreeItem(); + } + + async getChildren(element?: AzureDevCliModel): Promise { + if (element) { + return element.getChildren(); + } + + const applications = await this.applicationProvider.getApplications(); + const children: AzureDevCliModel[] = []; + + for (const application of applications) { + const configurationFilePath = application.configurationPath.fsPath; + const configurationFolder = application.configurationFolder; + const configurationFolderName = path.basename(configurationFolder); + + const workspaceResource: WorkspaceResource = { + folder: application.workspaceFolder, + id: configurationFilePath, + name: configurationFolderName, + resourceType: 'ms-azuretools.azure-dev.application' + }; + + const appModel = new AzureDevCliApplication( + workspaceResource, + (model: AzureDevCliModel) => this._onDidChangeTreeData.fire(model), + this.showProvider, + this.envListProvider, + this.envValuesProvider, + new Set(), + () => {}, + false // Do not include environments + ); + + children.push(appModel); + } + + return children; + } + + dispose(): void { + this.configFileWatcher.dispose(); + this._onDidChangeTreeData.dispose(); + } +} diff --git a/ext/vscode/src/views/registerViews.ts b/ext/vscode/src/views/registerViews.ts new file mode 100644 index 00000000000..00a17445854 --- /dev/null +++ b/ext/vscode/src/views/registerViews.ts @@ -0,0 +1,75 @@ +import * as vscode from 'vscode'; +import { HelpAndFeedbackTreeDataProvider } from './helpAndFeedback/HelpAndFeedbackTreeDataProvider'; +import { MyProjectTreeDataProvider } from './myProject/MyProjectTreeDataProvider'; +import { EnvironmentsTreeDataProvider, EnvironmentTreeItem, EnvironmentItem } from './environments/EnvironmentsTreeDataProvider'; +import { AzureDevCliEnvironmentVariable } from './workspace/AzureDevCliEnvironmentVariables'; +import { ExtensionsTreeDataProvider } from './extensions/ExtensionsTreeDataProvider'; +import { TemplateToolsTreeDataProvider } from './templateTools/TemplateToolsTreeDataProvider'; + +export function registerViews(context: vscode.ExtensionContext): void { + const helpAndFeedbackProvider = new HelpAndFeedbackTreeDataProvider(); + context.subscriptions.push( + vscode.window.registerTreeDataProvider('azure-dev.views.helpAndFeedback', helpAndFeedbackProvider) + ); + + const myProjectProvider = new MyProjectTreeDataProvider(); + context.subscriptions.push( + vscode.window.registerTreeDataProvider('azure-dev.views.myProject', myProjectProvider) + ); + context.subscriptions.push(myProjectProvider); + + const environmentsProvider = new EnvironmentsTreeDataProvider(); + context.subscriptions.push( + vscode.window.registerTreeDataProvider('azure-dev.views.environments', environmentsProvider) + ); + context.subscriptions.push(environmentsProvider); + context.subscriptions.push( + vscode.commands.registerCommand('azure-dev.views.environments.refresh', () => { + environmentsProvider.refresh(); + }) + ); + + const extensionsProvider = new ExtensionsTreeDataProvider(); + context.subscriptions.push( + vscode.window.registerTreeDataProvider('azure-dev.views.extensions', extensionsProvider) + ); + context.subscriptions.push( + vscode.commands.registerCommand('azure-dev.views.extensions.refresh', () => { + extensionsProvider.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('azure-dev.views.environments.toggleVisibility', (item: EnvironmentTreeItem) => { + environmentsProvider.toggleVisibility(item); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('azure-dev.commands.workspace.toggleVisibility', (item: AzureDevCliEnvironmentVariable) => { + item.toggleVisibility(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('azure-dev.views.environments.viewDotEnv', (item: EnvironmentTreeItem) => { + if (item.data && (item.data as EnvironmentItem).dotEnvPath) { + const envItem = item.data as EnvironmentItem; + if (envItem.dotEnvPath) { + void vscode.commands.executeCommand('vscode.open', vscode.Uri.file(envItem.dotEnvPath)); + } + } + }) + ); + + const templateToolsProvider = new TemplateToolsTreeDataProvider(); + context.subscriptions.push( + vscode.window.registerTreeDataProvider('azure-dev.views.templateTools', templateToolsProvider) + ); + context.subscriptions.push(templateToolsProvider); + context.subscriptions.push( + vscode.commands.registerCommand('azure-dev.views.templateTools.refresh', () => { + templateToolsProvider.refresh(); + }) + ); +} diff --git a/ext/vscode/src/views/templateTools/TemplateToolsTreeDataProvider.ts b/ext/vscode/src/views/templateTools/TemplateToolsTreeDataProvider.ts new file mode 100644 index 00000000000..3129194c9f4 --- /dev/null +++ b/ext/vscode/src/views/templateTools/TemplateToolsTreeDataProvider.ts @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as vscode from 'vscode'; +import { AzureDevTemplateProvider, Template, TemplateCategory } from '../../services/AzureDevTemplateProvider'; + +export class TemplateToolsTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + private readonly templateProvider: AzureDevTemplateProvider; + private configFileWatcher: vscode.FileSystemWatcher; + + constructor() { + this.templateProvider = new AzureDevTemplateProvider(); + + // Listen to azure.yaml file changes to toggle Quick Start visibility + this.configFileWatcher = vscode.workspace.createFileSystemWatcher( + '**/azure.{yml,yaml}', + false, false, false + ); + + const onFileChange = () => { + this.refresh(); + }; + + this.configFileWatcher.onDidCreate(onFileChange); + this.configFileWatcher.onDidDelete(onFileChange); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: TreeItemModel): vscode.TreeItem { + return element; + } + + async getChildren(element?: TreeItemModel): Promise { + if (!element) { + return this.getRootItems(); + } + + if (element instanceof QuickStartGroupItem) { + return this.getQuickStartItems(); + } + + if (element instanceof CategoryGroupItem) { + return this.getCategoryItems(); + } + + if (element instanceof CategoryItem) { + const templates = await this.templateProvider.getTemplatesByCategory(element.categoryName); + return templates.map(t => new TemplateItem(t, this.templateProvider)); + } + + if (element instanceof AITemplatesItem) { + const templates = await this.templateProvider.getAITemplates(); + return templates.map(t => new TemplateItem(t, this.templateProvider)); + } + + return []; + } + + private async getRootItems(): Promise { + const items: TreeItemModel[] = []; + const hasAzureYaml = await this.hasAzureYamlInWorkspace(); + + if (!hasAzureYaml) { + items.push(new QuickStartGroupItem()); + } + + items.push(new CategoryGroupItem(this.templateProvider)); + items.push(new AITemplatesItem(this.templateProvider)); + items.push(new SearchTemplatesItem()); + + return items; + } + + private getQuickStartItems(): TreeItemModel[] { + return [ + new InitFromCodeItem(), + new InitMinimalItem(), + new BrowseGalleryItem() + ]; + } + + private async getCategoryItems(): Promise { + const categories = this.templateProvider.getCategories(); + return categories.map(c => new CategoryItem(c, this.templateProvider)); + } + + private async hasAzureYamlInWorkspace(): Promise { + const files = await vscode.workspace.findFiles('**/azure.{yml,yaml}', '**/node_modules/**', 1); + return files.length > 0; + } + + dispose(): void { + this.configFileWatcher.dispose(); + this._onDidChangeTreeData.dispose(); + } +} + +// Base tree item model +abstract class TreeItemModel extends vscode.TreeItem {} + +// Root level items +class QuickStartGroupItem extends TreeItemModel { + constructor() { + super('Quick Start', vscode.TreeItemCollapsibleState.Expanded); + this.contextValue = 'quickStartGroup'; + this.tooltip = 'Get started with Azure Developer CLI'; + this.iconPath = new vscode.ThemeIcon('rocket'); + } +} + +class CategoryGroupItem extends TreeItemModel { + constructor(private templateProvider: AzureDevTemplateProvider) { + super('Browse by Category', vscode.TreeItemCollapsibleState.Collapsed); + this.contextValue = 'categoryGroup'; + this.tooltip = 'Browse templates by category'; + this.iconPath = new vscode.ThemeIcon('folder-library'); + } +} + +class AITemplatesItem extends TreeItemModel { + constructor(private templateProvider: AzureDevTemplateProvider) { + super('AI Templates', vscode.TreeItemCollapsibleState.Collapsed); + this.contextValue = 'aiTemplates'; + this.tooltip = 'AI and Machine Learning focused templates'; + this.iconPath = new vscode.ThemeIcon('sparkle'); + + // Async description update + void this.templateProvider.getAITemplates().then(templates => { + this.description = `${templates.length} templates`; + }); + } +} + +class SearchTemplatesItem extends TreeItemModel { + constructor() { + super('Search Templates...', vscode.TreeItemCollapsibleState.None); + this.contextValue = 'searchTemplates'; + this.tooltip = 'Search for templates'; + this.iconPath = new vscode.ThemeIcon('search'); + this.command = { + command: 'azure-dev.views.templateTools.search', + title: 'Search Templates' + }; + } +} + +// Quick start items +class InitFromCodeItem extends TreeItemModel { + constructor() { + super('Initialize from Current Code', vscode.TreeItemCollapsibleState.None); + this.contextValue = 'initFromCode'; + this.tooltip = 'Scan your current directory and generate Azure infrastructure'; + this.iconPath = new vscode.ThemeIcon('code'); + this.command = { + command: 'azure-dev.views.templateTools.initFromCode', + title: 'Initialize from Code' + }; + } +} + +class InitMinimalItem extends TreeItemModel { + constructor() { + super('Create Minimal Project', vscode.TreeItemCollapsibleState.None); + this.contextValue = 'initMinimal'; + this.tooltip = 'Create a minimal azure.yaml project file'; + this.iconPath = new vscode.ThemeIcon('file'); + this.command = { + command: 'azure-dev.views.templateTools.initMinimal', + title: 'Create Minimal Project' + }; + } +} + +class BrowseGalleryItem extends TreeItemModel { + constructor() { + super('Browse Template Gallery', vscode.TreeItemCollapsibleState.None); + this.contextValue = 'browseGallery'; + this.tooltip = 'Open Azure Developer CLI templates gallery in browser'; + this.iconPath = new vscode.ThemeIcon('globe'); + this.command = { + command: 'azure-dev.views.templateTools.openGallery', + title: 'Browse Gallery' + }; + } +} + +// Category item +class CategoryItem extends TreeItemModel { + constructor( + public readonly category: TemplateCategory, + private templateProvider: AzureDevTemplateProvider + ) { + super(category.displayName, vscode.TreeItemCollapsibleState.Collapsed); + this.contextValue = 'templateCategory'; + this.tooltip = `Browse ${category.displayName} templates`; + this.iconPath = new vscode.ThemeIcon('folder'); + + // Async description update + void this.templateProvider.getTemplatesByCategory(category.name).then(templates => { + this.description = `${templates.length} templates`; + }); + } + + get categoryName(): string { + return this.category.name; + } +} + +// Template item +class TemplateItem extends TreeItemModel { + constructor( + public readonly template: Template, + private templateProvider: AzureDevTemplateProvider + ) { + super(template.title, vscode.TreeItemCollapsibleState.None); + this.contextValue = 'template'; + this.tooltip = new vscode.MarkdownString( + `**${template.title}**\n\n${template.description}\n\n` + + `Author: ${template.author}\n\n` + + `[View on GitHub](${template.source})` + ); + this.description = template.author; + this.iconPath = new vscode.ThemeIcon('symbol-class'); + + // Click to open README + this.command = { + command: 'azure-dev.views.templateTools.openReadme', + title: 'View README', + arguments: [template] + }; + } +} diff --git a/ext/vscode/src/views/workspace/AzureDevCliApplication.ts b/ext/vscode/src/views/workspace/AzureDevCliApplication.ts index 36804b8cc6e..7262632ddcb 100644 --- a/ext/vscode/src/views/workspace/AzureDevCliApplication.ts +++ b/ext/vscode/src/views/workspace/AzureDevCliApplication.ts @@ -11,6 +11,7 @@ import { AzureDevCliServices } from './AzureDevCliServices'; import { AzDevShowResults, AzureDevShowProvider } from '../../services/AzureDevShowProvider'; import { AsyncLazy } from '../../utils/lazy'; import { AzureDevEnvListProvider } from '../../services/AzureDevEnvListProvider'; +import { AzureDevEnvValuesProvider } from '../../services/AzureDevEnvValuesProvider'; export class AzureDevCliApplication implements AzureDevCliModel { private results: AsyncLazy; @@ -19,7 +20,11 @@ export class AzureDevCliApplication implements AzureDevCliModel { private readonly resource: WorkspaceResource, private readonly refresh: RefreshHandler, private readonly showProvider: AzureDevShowProvider, - private readonly envListProvider: AzureDevEnvListProvider) { + private readonly envListProvider: AzureDevEnvListProvider, + private readonly envValuesProvider: AzureDevEnvValuesProvider, + private readonly visibleEnvVars: Set, + private readonly onToggleVisibility: (key: string) => void, + private readonly includeEnvironments: boolean = true) { this.results = new AsyncLazy(() => this.getResults()); } @@ -30,10 +35,22 @@ export class AzureDevCliApplication implements AzureDevCliModel { async getChildren(): Promise { const results = await this.results.getValue(); - return [ - new AzureDevCliServices(this.context, Object.keys(results?.services ?? {})), - new AzureDevCliEnvironments(this.context, this.refresh, this.envListProvider) + const children: AzureDevCliModel[] = [ + new AzureDevCliServices(this.context, Object.keys(results?.services ?? {})) ]; + + if (this.includeEnvironments) { + children.push(new AzureDevCliEnvironments( + this.context, + this.refresh, + this.envListProvider, + this.envValuesProvider, + this.visibleEnvVars, + this.onToggleVisibility + )); + } + + return children; } async getTreeItem(): Promise { @@ -55,4 +72,4 @@ export class AzureDevCliApplication implements AzureDevCliModel { } ) as Promise; } -} \ No newline at end of file +} diff --git a/ext/vscode/src/views/workspace/AzureDevCliEnvironment.ts b/ext/vscode/src/views/workspace/AzureDevCliEnvironment.ts index 2fc52585f08..5fdc89f1272 100644 --- a/ext/vscode/src/views/workspace/AzureDevCliEnvironment.ts +++ b/ext/vscode/src/views/workspace/AzureDevCliEnvironment.ts @@ -4,30 +4,45 @@ import * as vscode from 'vscode'; import { AzureDevCliModel } from './AzureDevCliModel'; import { AzureDevCliEnvironmentsModelContext } from './AzureDevCliEnvironments'; +import { AzureDevEnvValuesProvider } from '../../services/AzureDevEnvValuesProvider'; +import { AzureDevCliEnvironmentVariables } from './AzureDevCliEnvironmentVariables'; export class AzureDevCliEnvironment implements AzureDevCliModel { constructor( public readonly context: AzureDevCliEnvironmentsModelContext, public readonly name: string, private readonly isDefault: boolean, - public readonly environmentFile: vscode.Uri | undefined) { + public readonly environmentFile: vscode.Uri | undefined, + private readonly envValuesProvider: AzureDevEnvValuesProvider, + private readonly visibleEnvVars: Set, + private readonly onToggleVisibility: (key: string) => void) { } getChildren(): AzureDevCliModel[] { - return []; + return [ + new AzureDevCliEnvironmentVariables( + this.context, + this.envValuesProvider, + this.name, + this.visibleEnvVars, + this.onToggleVisibility + ) + ]; } getTreeItem(): vscode.TreeItem { - const treeItem = new vscode.TreeItem(this.name); + const treeItem = new vscode.TreeItem(this.name, vscode.TreeItemCollapsibleState.Collapsed); treeItem.contextValue = 'ms-azuretools.azure-dev.views.workspace.environment'; - treeItem.iconPath = new vscode.ThemeIcon('cloud'); if (this.isDefault) { treeItem.contextValue += ';default'; - treeItem.description = vscode.l10n.t('(default)'); + treeItem.description = vscode.l10n.t('(Current)'); + treeItem.iconPath = new vscode.ThemeIcon('pass', new vscode.ThemeColor('testing.iconPassed')); + } else { + treeItem.iconPath = new vscode.ThemeIcon('circle-large-outline'); } return treeItem; } -} \ No newline at end of file +} diff --git a/ext/vscode/src/views/workspace/AzureDevCliEnvironmentVariables.ts b/ext/vscode/src/views/workspace/AzureDevCliEnvironmentVariables.ts new file mode 100644 index 00000000000..8f15b681a74 --- /dev/null +++ b/ext/vscode/src/views/workspace/AzureDevCliEnvironmentVariables.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as vscode from 'vscode'; +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; +import { TelemetryId } from '../../telemetry/telemetryId'; +import { AzureDevEnvValuesProvider } from '../../services/AzureDevEnvValuesProvider'; +import { AzureDevCliModel, AzureDevCliModelContext } from './AzureDevCliModel'; + +export class AzureDevCliEnvironmentVariables implements AzureDevCliModel { + constructor( + public readonly context: AzureDevCliModelContext, + private readonly envValuesProvider: AzureDevEnvValuesProvider, + private readonly environmentName: string, + private readonly visibleEnvVars: Set, + private readonly onToggleVisibility: (key: string) => void + ) {} + + async getChildren(): Promise { + const values = await callWithTelemetryAndErrorHandling( + TelemetryId.WorkspaceViewEnvironmentResolve, + async (context) => { + return await this.envValuesProvider.getEnvValues(context, this.context.configurationFile, this.environmentName); + } + ) ?? {}; + + return Object.entries(values).map(([key, value]) => { + return new AzureDevCliEnvironmentVariable(this.context, this.environmentName, key, value, this.visibleEnvVars, this.onToggleVisibility); + }); + } + + getTreeItem(): vscode.TreeItem { + const item = new vscode.TreeItem(vscode.l10n.t('Environment Variables'), vscode.TreeItemCollapsibleState.Collapsed); + item.iconPath = new vscode.ThemeIcon('symbol-variable'); + item.contextValue = 'ms-azuretools.azure-dev.views.workspace.environmentVariables'; + return item; + } +} + +export class AzureDevCliEnvironmentVariable implements AzureDevCliModel { + constructor( + public readonly context: AzureDevCliModelContext, + private readonly environmentName: string, + private readonly key: string, + private readonly value: string, + private readonly visibleEnvVars: Set, + private readonly onToggleVisibility: (key: string) => void + ) {} + + getChildren(): AzureDevCliModel[] { + return []; + } + + getTreeItem(): vscode.TreeItem { + const id = `${this.environmentName}/${this.key}`; + const isVisible = this.visibleEnvVars.has(id); + const label = isVisible ? `${this.key}=${this.value}` : `${this.key}=Hidden value. Click to view.`; + + const item = new vscode.TreeItem(label); + item.tooltip = isVisible ? `${this.key}=${this.value}` : 'Click to view value'; + item.iconPath = new vscode.ThemeIcon('key'); + item.contextValue = 'ms-azuretools.azure-dev.views.workspace.environmentVariable'; + + item.command = { + command: 'azure-dev.commands.workspace.toggleVisibility', + title: vscode.l10n.t('Toggle Visibility'), + arguments: [this] + }; + + return item; + } + + toggleVisibility(): void { + const id = `${this.environmentName}/${this.key}`; + this.onToggleVisibility(id); + } +} diff --git a/ext/vscode/src/views/workspace/AzureDevCliEnvironments.ts b/ext/vscode/src/views/workspace/AzureDevCliEnvironments.ts index 3b2a619f194..7dac13024a1 100644 --- a/ext/vscode/src/views/workspace/AzureDevCliEnvironments.ts +++ b/ext/vscode/src/views/workspace/AzureDevCliEnvironments.ts @@ -7,6 +7,7 @@ import { TelemetryId } from '../../telemetry/telemetryId'; import { AzureDevCliEnvironment } from './AzureDevCliEnvironment'; import { AzureDevCliModel, AzureDevCliModelContext, RefreshHandler } from "./AzureDevCliModel"; import { AzDevEnvListResults, AzureDevEnvListProvider } from '../../services/AzureDevEnvListProvider'; +import { AzureDevEnvValuesProvider } from '../../services/AzureDevEnvValuesProvider'; export interface AzureDevCliEnvironmentsModelContext extends AzureDevCliModelContext { refreshEnvironments(): void; @@ -16,7 +17,10 @@ export class AzureDevCliEnvironments implements AzureDevCliModel { constructor( context: AzureDevCliModelContext, refresh: RefreshHandler, - private readonly envListProvider: AzureDevEnvListProvider) { + private readonly envListProvider: AzureDevEnvListProvider, + private readonly envValuesProvider: AzureDevEnvValuesProvider, + private readonly visibleEnvVars: Set, + private readonly onToggleVisibility: (key: string) => void) { this.context = { ...context, refreshEnvironments: () => refresh(this) @@ -29,14 +33,17 @@ export class AzureDevCliEnvironments implements AzureDevCliModel { const envListResults = await this.getResults() ?? []; const environments: AzureDevCliModel[] = []; - + for (const environment of envListResults) { environments.push( new AzureDevCliEnvironment( this.context, environment.Name ?? '', environment.IsDefault ?? false, - environment.DotEnvPath ? vscode.Uri.file(environment.DotEnvPath) : undefined)); + environment.DotEnvPath ? vscode.Uri.file(environment.DotEnvPath) : undefined, + this.envValuesProvider, + this.visibleEnvVars, + this.onToggleVisibility)); } return environments; @@ -58,4 +65,4 @@ export class AzureDevCliEnvironments implements AzureDevCliModel { } ) as Promise; } -} \ No newline at end of file +} diff --git a/ext/vscode/src/views/workspace/AzureDevCliWorkspaceResourceBranchDataProvider.ts b/ext/vscode/src/views/workspace/AzureDevCliWorkspaceResourceBranchDataProvider.ts index 7ca80571266..46bcf926932 100644 --- a/ext/vscode/src/views/workspace/AzureDevCliWorkspaceResourceBranchDataProvider.ts +++ b/ext/vscode/src/views/workspace/AzureDevCliWorkspaceResourceBranchDataProvider.ts @@ -6,15 +6,18 @@ import * as vscode from 'vscode'; import { ProviderResult, TreeItem } from 'vscode'; import { AzureDevEnvListProvider, WorkspaceAzureDevEnvListProvider } from '../../services/AzureDevEnvListProvider'; import { AzureDevShowProvider, WorkspaceAzureDevShowProvider } from '../../services/AzureDevShowProvider'; +import { AzureDevEnvValuesProvider, WorkspaceAzureDevEnvValuesProvider } from '../../services/AzureDevEnvValuesProvider'; import { AzureDevCliApplication } from './AzureDevCliApplication'; import { AzureDevCliModel } from './AzureDevCliModel'; export class AzureDevCliWorkspaceResourceBranchDataProvider extends vscode.Disposable implements BranchDataProvider { private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + private readonly visibleEnvVars = new Set(); constructor( private readonly showProvider: AzureDevShowProvider = new WorkspaceAzureDevShowProvider(), - private readonly envListProvider: AzureDevEnvListProvider = new WorkspaceAzureDevEnvListProvider() + private readonly envListProvider: AzureDevEnvListProvider = new WorkspaceAzureDevEnvListProvider(), + private readonly envValuesProvider: AzureDevEnvValuesProvider = new WorkspaceAzureDevEnvValuesProvider() ) { super( () => { @@ -27,7 +30,24 @@ export class AzureDevCliWorkspaceResourceBranchDataProvider extends vscode.Dispo } getResourceItem(element: WorkspaceResource): AzureDevCliModel | Thenable { - return new AzureDevCliApplication(element, model => this.onDidChangeTreeDataEmitter.fire(model), this.showProvider, this.envListProvider); + return new AzureDevCliApplication( + element, + model => this.onDidChangeTreeDataEmitter.fire(model), + this.showProvider, + this.envListProvider, + this.envValuesProvider, + this.visibleEnvVars, + (key) => this.toggleVisibility(key) + ); + } + + toggleVisibility(key: string): void { + if (this.visibleEnvVars.has(key)) { + this.visibleEnvVars.delete(key); + } else { + this.visibleEnvVars.add(key); + } + this.onDidChangeTreeDataEmitter.fire(); } createResourceItem?: (() => ProviderResult) | undefined; @@ -37,4 +57,4 @@ export class AzureDevCliWorkspaceResourceBranchDataProvider extends vscode.Dispo getTreeItem(element: AzureDevCliModel): TreeItem | Thenable { return element.getTreeItem(); } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000000..1a128281355 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "azure-dev", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}