diff --git a/.agents/skills/ux-walkthrough/catalog.md b/.agents/skills/ux-walkthrough/catalog.md index 9535f03775..137f9fe949 100644 --- a/.agents/skills/ux-walkthrough/catalog.md +++ b/.agents/skills/ux-walkthrough/catalog.md @@ -12,7 +12,7 @@ Legend: **Non-AI** = works without `ansibleEnvironments.enableAiFeatures`. | Feature | Trigger | Description | Non-AI | Implementation | | ---------------------- | ---------------- | -------------------------------------------------------------------------------------------------- | ------ | -------------------------------- | | Activity bar container | Ansible icon | Opens Ansible sidebar | yes | `package.json` `viewsContainers` | -| Tree views (10) | Sidebar scroll | Env, Dev Tools, Collections, Sources, EEs, Creator, Playbooks, AI Tools*, AI Skills*, Lightspeed\* | yes\* | `src/views/*Provider.ts` | +| Ansible | Activity Bar | Accordion sections (Env, Dev Tools, Collections, Sources, EEs, Creator, Playbooks, AI Tools*, AI Skills*, Lightspeed\*) | yes\* | `src/sidebar/AnsibleNavTreeProvider.ts` + `src/views/*Controller.ts` | | Output channel | Output → Ansible | Extension logs | yes | `src/extension.ts` | \*AI views require `enableAiFeatures`; Lightspeed view requires `ansible.lightspeed.enabled`. @@ -25,11 +25,11 @@ Legend: **Non-AI** = works without `ansibleEnvironments.enableAiFeatures`. | Feature | Command / trigger | Description | Non-AI | Implementation | | -------------------- | ------------------------------------ | ------------------------------ | ------ | -------------------------------- | -| Environment Managers | View: `ansibleDevToolsEnvManagers` | Python managers → environments | yes | `EnvironmentManagersProvider.ts` | +| Environment Managers | Sidebar section: `envManagers` | Python managers → environments | yes | `EnvironmentManagersController.ts` | | Refresh environments | `ansibleDevToolsEnvManagers.refresh` | Reload env tree | yes | | | Create environment | `ansibleDevToolsEnvManagers.create` | One-click venv creation | yes | | | Select environment | `ansibleDevTools.selectEnvironment` | Active env for Ansible tools | yes | | -| Ansible Dev Tools | View: `ansibleDevToolsPackages` | ADT package versions | yes | `AnsibleDevToolsProvider.ts` | +| Ansible Dev Tools | Sidebar section: `devTools` | ADT package versions | yes | `AnsibleDevToolsController.ts` | | Install ADT | `ansibleDevToolsPackages.install` | Install meta-package | yes | | | Upgrade ADT | `ansibleDevToolsPackages.upgrade` | Upgrade packages | yes | | | Python status bar | `ansible.statusBar.pythonClick` | Env details QuickPick | yes | `statusBar/pythonStatusBar.ts` | @@ -63,7 +63,7 @@ Legend: **Non-AI** = works without `ansibleEnvironments.enableAiFeatures`. | Feature | Command / trigger | Description | Non-AI | Implementation | | --------------------- | ------------------------------------------ | --------------------------- | ------ | --------------------------- | -| Collections tree | View: `ansibleDevToolsCollections` | Collection → type → plugin | yes | `CollectionsProvider.ts` | +| Collections tree | View: `ansibleDevToolsCollections` | Collection → type → plugin | yes | `CollectionsController.ts` | | Refresh | `ansibleDevToolsCollections.refresh` | Reload index | yes | | | Search plugins | `ansibleDevToolsCollections.search` | Keyword search | yes | | | Plugin documentation | `ansibleDevToolsCollections.showPluginDoc` | Rich doc webview | yes | `PluginDocPanel.ts` | @@ -78,7 +78,7 @@ Legend: **Non-AI** = works without `ansibleEnvironments.enableAiFeatures`. | Feature | Command / trigger | Description | Non-AI | Implementation | | ------------------- | -------------------------------------------------- | -------------------- | ------ | ------------------------------ | -| Sources tree | View: `ansibleCollectionSources` | Galaxy + GitHub orgs | yes | `CollectionSourcesProvider.ts` | +| Sources tree | View: `ansibleCollectionSources` | Galaxy + GitHub orgs | yes | `CollectionSourcesController.ts` | | Search collections | `ansibleCollectionSources.search` | Unified search | yes | | | Filter Galaxy | `ansibleCollectionSources.filterGalaxyCollections` | Filter list | yes | | | Galaxy plugin docs | `ansibleCollectionSources.showGalaxyPluginDoc` | Uninstalled docs | yes | `GalaxyDocsCache` | @@ -95,7 +95,7 @@ Legend: **Non-AI** = works without `ansibleEnvironments.enableAiFeatures`. | Feature | Command / trigger | Description | Non-AI | Implementation | | ---------------- | -------------------------- | ------------------------ | ------ | ------------------------ | -| Creator tree | View: `ansibleCreator` | ansible-creator commands | yes | `CreatorProvider.ts` | +| Creator tree | View: `ansibleCreator` | ansible-creator commands | yes | `CreatorController.ts` | | Open form | `ansibleCreator.openForm` | Schema-driven webview | yes | `CreatorFormPanel.ts` | | Live CLI preview | Creator form | Exact command preview | yes | `@ansible/ui` SchemaForm | | AI overview\* | `ansibleCreator.aiSummary` | Chat prompt | no | | @@ -108,7 +108,7 @@ Legend: **Non-AI** = works without `ansibleEnvironments.enableAiFeatures`. | Feature | Command / trigger | Description | Non-AI | Implementation | | --------------------- | ---------------------------------- | ---------------------------- | ------ | -------------------------- | -| Playbooks tree | View: `ansiblePlaybooks` | Workspace playbook discovery | yes | `PlaybooksProvider.ts` | +| Playbooks tree | View: `ansiblePlaybooks` | Workspace playbook discovery | yes | `PlaybooksController.ts` | | Go to play | `ansiblePlaybooks.goToPlay` | Editor navigation | yes | | | Edit config | `ansiblePlaybooks.editConfig` | Per-playbook run form | yes | `PlaybookConfigPanel.ts` | | Edit defaults | `ansiblePlaybooks.editDefaults` | Global defaults form | yes | | @@ -125,7 +125,7 @@ Legend: **Non-AI** = works without `ansibleEnvironments.enableAiFeatures`. | Feature | Command / trigger | Description | Non-AI | Implementation | | -------------- | ------------------------------------------------ | ---------------------------- | ------ | ---------------------------------- | -| EE tree | View: `ansibleExecutionEnvironments` | Images via ansible-navigator | yes | `ExecutionEnvironmentsProvider.ts` | +| EE tree | View: `ansibleExecutionEnvironments` | Images via ansible-navigator | yes | `ExecutionEnvironmentsController.ts` | | EE detail | `ansibleExecutionEnvironments.showDetail` | Metadata webview | yes | `EEDetailPanel.ts` | | Package detail | `ansibleExecutionEnvironments.showPackageDetail` | Python/system packages | yes | `PackageDetailPanel.ts` | | AI summary\* | `ansibleExecutionEnvironments.aiSummary` | Chat prompt | no | | @@ -155,10 +155,10 @@ See `packages/common/src/skills/`. | Feature | Command / trigger | Description | Implementation | | ---------------------- | ----------------------------------------- | ------------------------------------------------------ | ---------------------- | -| AI Tools tree | View: `ansibleMcpTools` | MCP tools by category | `McpToolsProvider.ts` | +| AI Tools tree | View: `ansibleMcpTools` | MCP tools by category | `McpToolsController.ts` | | Use in Chat | `ansibleMcpTools.useInChat` | Inject tool prompt | | | Copy prompt | `ansibleMcpTools.copyPrompt` | Opens chat with pre-filled prompt (clipboard fallback) | | -| AI Skills tree | View: `ansibleSkills` | External + builtin skills | `SkillsProvider.ts` | +| AI Skills tree | View: `ansibleSkills` | External + builtin skills | `SkillsController.ts` | | MCP status | `ansible-environments.showMcpStatus` | Connection webview | `mcp/cursorConfig.ts` | | Configure Cursor MCP | `ansible-environments.configureCursorMcp` | Guided setup | | | MCP server (23+ tools) | External agent | Discovery, generation, EE, creator | `packages/mcp-server/` | diff --git a/.gitignore b/.gitignore index 4fda317597..23f850ce31 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ lib/ .ansible/ docs/.astro/ docs/dist/ +packages/ui/dist-playground/ packages/common/src/skills/*.content.ts **/*.tgz .cursor/hooks/state/continual-learning.json diff --git a/.sdlc/adrs/ADR-006-esbuild-bundler.md b/.sdlc/adrs/ADR-006-esbuild-bundler.md index 9194e352f0..007e34ecdc 100644 --- a/.sdlc/adrs/ADR-006-esbuild-bundler.md +++ b/.sdlc/adrs/ADR-006-esbuild-bundler.md @@ -21,7 +21,7 @@ aliases during type checking but does **not** rewrite import paths in the emitted JavaScript. As a result: ```typescript -// src/views/CollectionsProvider.ts +// src/views/CollectionsController.ts import { CollectionsService } from '@src/services/CollectionsService'; // Compiles to: const { CollectionsService } = require('@src/services/CollectionsService'); diff --git a/.sdlc/adrs/ADR-013-scm-plugin-docs-via-shallow-clone.md b/.sdlc/adrs/ADR-013-scm-plugin-docs-via-shallow-clone.md index 514109482a..0471c5405e 100644 --- a/.sdlc/adrs/ADR-013-scm-plugin-docs-via-shallow-clone.md +++ b/.sdlc/adrs/ADR-013-scm-plugin-docs-via-shallow-clone.md @@ -185,7 +185,7 @@ authoritative tool. - `packages/services/src/SCMDocsCache.ts` — new service - `packages/common/src/parsers/metadataDumpParser.ts` — shared browser-safe parsing utility extracted from `CollectionsService` -- `src/views/CollectionSourcesProvider.ts` — tree expansion for +- `src/views/CollectionSourcesController.ts` — tree expansion for GitHub collection nodes - `packages/mcp-server/src/tools.ts` + `handlers.ts` — MCP tool diff --git a/.sdlc/adrs/ADR-025-host-agnostic-sidebar-navtree.md b/.sdlc/adrs/ADR-025-host-agnostic-sidebar-navtree.md new file mode 100644 index 0000000000..70c60bcdd6 --- /dev/null +++ b/.sdlc/adrs/ADR-025-host-agnostic-sidebar-navtree.md @@ -0,0 +1,141 @@ +# ADR-025: Sidebar NavTree + +## Status + +Implemented + +## Date + +2026-07-17 + +## Context + +The Ansible Activity Bar previously stacked many native `TreeView` sections. Users want a +quieter drawer: accordion (one open at a time), collapsed by default, and auto-open when +Environment Managers or Ansible Dev Tools have a known issue. VS Code cannot collapse +sibling view headers from an extension +([vscode#88219](https://github.com/microsoft/vscode/issues/88219)). + +We also intend a future standalone Electron app that reuses the same IA without +`vscode.TreeDataProvider`. Hydration and action dispatch must not live only inside +extension TreeProviders or React components. + +## Decision + +**We ship a host-agnostic sidebar NavTree: a serializable `SidebarSnapshot` model hydrated +in `@ansible/developer-services`, rendered by React in `@ansible/ui`, with VS Code as +the first host adapter (`WebviewView`). Native Activity Bar TreeViews are removed; the +hub is the sole Ansible sidebar UI.** + +Concrete rules: + +1. **DTOs** (`SidebarSnapshot`, nodes, welcome/inline actions) live in `@ansible/common` + (browser-safe). +2. **`SidebarModel`** in `@ansible/developer-services` builds snapshots from services / + plain inputs and computes `suggestedOpenSectionId`. Lazy-expand node builders + (`buildEeDetailNodes`, `buildPluginTypeNodes`, `patchNodeChildren`) live here too. + No `vscode` types in the DTO. +3. **React** (`SidebarShell` / `SidebarTree`) owns only presentation and ephemeral UI + state (accordion, row expand). Actions are command-id strings dispatched to the host. +4. **VS Code host** registers `ansibleNavTree` (`type: webview`) as the only view + under `views.ansible-environments`. Controllers in `src/views/` remain for command + handlers and change events; they are not `TreeDataProvider`s. +5. **AI Tools / AI Skills / Lightspeed** appear as hub sections when the corresponding + feature flags are on (not separate Activity Bar trees). +6. **Electron** later swaps only the transport (IPC vs postMessage) and command + registry; it reuses model + React. +7. **Section ids** (`SidebarSectionId`, `SidebarNodeExpand`) stay Ansible-specific for + this product IA. Generalizing the hub into a free-form accordion toolkit is out of + scope. + +## Alternatives Considered + +### Alternative 1: Accordion via native TreeView APIs only + +**Description**: Keep seven trees; try to collapse siblings with undocumented or +future APIs. + +**Pros**: Native look; no webview cost. + +**Cons**: Not supported today (#88219); blocks Electron reuse. + +**Why not chosen**: Cannot meet accordion / quiet-by-default requirements. + +### Alternative 2: Put hydration in React / webview + +**Description**: Webview calls services over a fat RPC and owns population logic. + +**Pros**: Less extension code. + +**Cons**: Duplicates provider logic; harder to share with MCP/Electron; browser cannot +run Node services. + +**Why not chosen**: Violates ADR-011 and Electron readiness. + +### Alternative 3: Keep TreeViews behind a setting (dogfood toggle) + +**Description**: Hide native trees when `ansible.sidebar.navTree.enabled` is true; keep +`createTreeView` as a fallback. + +**Pros**: Escape hatch during migration. + +**Cons**: Dual UI maintenance; menus/`viewsWelcome` drift from hub snapshot actions. + +**Why not chosen**: After side-by-side dogfood, trees were hard-removed. Commands remain; +only TreeView chrome was deleted. + +## Consequences + +### Positive + +- Accordion and issue-driven open are owned by us. +- Same model + UI path for VS Code webview and future Electron. +- Single Activity Bar surface — no dual tree/hub chrome. + +### Negative + +- Webview fidelity will never be pixel-identical to native TreeView chrome. +- Controllers expose `onDidChange` for hub refresh (not TreeViews). + +### Neutral + +- Inline actions and welcome buttons are data on the snapshot, replacing former + `viewsWelcome` and `view/item/context` inline menus for the sidebar. + +## Implementation Notes + +- No `ansible.sidebar.navTree.enabled` setting — hub is always on. +- View id: `ansibleNavTree` (only entry in `views.ansible-environments`). +- Host: `src/sidebar/AnsibleNavTreeProvider.ts` (`WebviewViewProvider`). +- Controllers (hydrate + commands, no TreeViews): `src/views/*Controller.ts`. +- Bridge messages: `sidebar/setState`, `sidebar/action`, `sidebar/expandNode`, + `sidebar/ready`. +- Progressive hydrate: host posts a skeleton `SidebarSnapshot` (section headers + + loading rows) synchronously, then a full snapshot after async service work. + Incremental refreshes skip the skeleton. UI is custom `SidebarShell` / + `SidebarTree` (not react-arborist). +- Open section uses flex fill so trailing headers stay pinned to the bottom of the hub. +- Section builders live under `packages/services/src/sidebar/` with an ordered + `SECTION_REGISTRY` (see [.sdlc/docs/add-navtree-section.md](../docs/add-navtree-section.md)). + +## Related Decisions + +- ADR-005: Architectural invariants +- ADR-010: Shared UI package (historical; UI in `@ansible/ui`) +- ADR-011: Package architecture (`@ansible/common` / `@ansible/developer-services`) +- ADR-012: MCP tool parity (hub actions must map to existing commands/tools) + +## References + +- [vscode#88219](https://github.com/microsoft/vscode/issues/88219) — cannot collapse other views +- Phase 0 playground: `packages/ui/playground/sidebar-navtree.html` + +--- + +## Revision History + +| Date | Author | Change | +| ---------- | ---------- | ---------------------------------------------- | +| 2026-07-17 | bthornto | Initial decision | +| 2026-07-18 | bthornto | Progressive skeleton hydrate; custom tree UI | +| 2026-07-19 | bthornto | Hard-remove native trees; NavTree-only UI | diff --git a/.sdlc/adrs/README.md b/.sdlc/adrs/README.md index af9f6c672a..6d25669f86 100644 --- a/.sdlc/adrs/README.md +++ b/.sdlc/adrs/README.md @@ -20,6 +20,7 @@ Decisions that are fully reflected in the codebase. | [ADR-020](ADR-020-single-repo-multi-distribution.md) | Single Repository, Multiple Distribution Formats | 2026-06-26 | | [ADR-022](ADR-022-pnpm-package-manager.md) | Migrate to pnpm for Supply-Chain Security | 2026-06-30 | | [ADR-024](ADR-024-host-agnostic-getting-started.md) | Host-Agnostic Getting Started (Shared Walkthrough Content) | 2026-07-17 | +| [ADR-025](ADR-025-host-agnostic-sidebar-navtree.md) | Sidebar NavTree | 2026-07-17 | ## Accepted @@ -48,7 +49,7 @@ Decisions under consideration — not yet accepted or implemented. ## Creating New ADRs 1. Copy the template from `../templates/adr.md` -2. Use the next available number (currently ADR-025) +2. Use the next available number (currently ADR-026) 3. Include: - Status (Proposed → Accepted → Implemented) - Date diff --git a/.sdlc/docs/add-navtree-section.md b/.sdlc/docs/add-navtree-section.md new file mode 100644 index 0000000000..f20bc8db2f --- /dev/null +++ b/.sdlc/docs/add-navtree-section.md @@ -0,0 +1,42 @@ +# Add an Ansible NavTree section + +Use this checklist when adding a new accordion section (e.g. Molecule scenarios). +Do **not** change `SidebarShell` / `SidebarTree` for ordinary sections. + +## Steps + +1. **Id** — Add the section id to `SidebarSectionId` in + [`packages/common/src/sidebar.ts`](../../packages/common/src/sidebar.ts). + If the section needs lazy expand, add a `SidebarNodeExpand` variant there too. + +2. **Builder** — Create `packages/services/src/sidebar/sections/.ts` exporting + `build(input): SidebarSection` and, if needed, + `shouldSuggest(input): boolean`. + +3. **Registry** — Register the section in + [`packages/services/src/sidebar/registry.ts`](../../packages/services/src/sidebar/registry.ts) + (`SECTION_REGISTRY` order = accordion order). Skeleton and full snapshots both + use this list. + +4. **Input** — Extend `SidebarModelInput` in + [`packages/services/src/sidebar/types.ts`](../../packages/services/src/sidebar/types.ts). + Gather data in the VS Code host + ([`AnsibleNavTreeProvider`](../../src/sidebar/AnsibleNavTreeProvider.ts)); + shape plain DTOs in + [`assembleSidebarInput.ts`](../../packages/services/src/sidebar/assembleSidebarInput.ts) + when the mapping is non-trivial. + +5. **Commands + MCP** — Wire row/header actions to existing or new commands. + Per ADR-012, user-facing capabilities need an MCP tool equivalent. + +6. **Tests** — Unit-test the section builder (and suggest, if any). Keep + `SidebarModel` orchestrator tests green. Optional: add a playground mock row. + +7. **Story** — If the section is user-facing, add/update a user story in + `.sdlc/user-stories.yaml` and tag WDIO coverage when applicable. + +## Guarantees + +- UI only renders `SidebarSnapshot` — no Ansible discovery in the webview. +- Host owns vscode config/auth/workspace; services own pure snapshot building. +- Controllers under `src/views/` expose `onDidChange` for NavTree refresh (not TreeViews). diff --git a/.sdlc/research/sidebar-navtree-native-parity-audit.md b/.sdlc/research/sidebar-navtree-native-parity-audit.md new file mode 100644 index 0000000000..593a11cc9e --- /dev/null +++ b/.sdlc/research/sidebar-navtree-native-parity-audit.md @@ -0,0 +1,27 @@ +# NavTree Parity Audit + +Date: 2026-07-19 (post hard-remove cutover) +Method: Dogfood + package.json / SidebarModel / hub host +Scope: ADR-025 accordion hub (native trees removed) + +## Cutover status + +| Item | Status | +| --- | --- | +| NavTree-only Activity Bar view (`ansibleNavTree`) | **Done** | +| Native TreeViews / `createTreeView` | **Removed** | +| `ansible.sidebar.navTree.enabled` setting | **Removed** | +| Controllers kept for commands + change events | **Done** | +| Lazy expand builders in `SidebarModel` | **Done** | + +## Historical P0–P2 gaps + +Pre-cutover parity items (env select, EE lazy tree, collections indexing, tooltips, +python-only welcome, etc.) were fixed during dogfood. See git history on `next` / +feature branches for detail. This document no longer tracks dual-UI gaps. + +## Follow-ups (out of scope for cutover) + +- Generalize `SidebarSectionId` for non-Ansible consumers +- Rename controller `onDidChangeTreeData` to a neutral change event +- Lit / vscode-elements contribution of accordion primitives diff --git a/.sdlc/user-stories.yaml b/.sdlc/user-stories.yaml index 07d61bb0d5..dc42e1ba3c 100644 --- a/.sdlc/user-stories.yaml +++ b/.sdlc/user-stories.yaml @@ -35,7 +35,7 @@ stories: prd_refs: [US-1, AC-1] requires_ai: false criteria: - - Environment Managers tree view populates with discovered environments + - Environment Managers sidebar section populates with discovered environments - Environments are grouped under their manager type - Each environment shows its Python path or version @@ -75,7 +75,7 @@ stories: prd_refs: [US-2, AC-1] requires_ai: false criteria: - - Dev Tools tree view shows installed packages with version numbers + - Dev Tools sidebar section shows installed packages with version numbers - Packages include ansible-lint, ansible-navigator, ansible-creator, etc. - id: ENV-005 @@ -232,7 +232,7 @@ stories: prd_refs: [US-3, AC-2] requires_ai: false criteria: - - Collections tree view shows installed collections + - Collections sidebar section shows installed collections - Each collection expands to show plugin types, then individual plugins - Counts, versions, and descriptions are visible @@ -338,7 +338,7 @@ stories: prd_refs: [US-5, AC-3] requires_ai: false criteria: - - Creator view shows available ansible-creator subcommands + - Creator sidebar section shows available ansible-creator subcommands - Opening a form presents fields for the selected scaffold type - Submitting the form creates the correct directory structure @@ -366,7 +366,7 @@ stories: requires_ai: false criteria: - Schema is read at runtime from the installed ansible-creator - - New subcommands appear in the Creator view automatically + - New subcommands appear in the Creator sidebar section automatically # --------------------------------------------------------------------------- # PLB — Playbook Execution & Visualization @@ -381,7 +381,7 @@ stories: prd_refs: [US-6, AC-4] requires_ai: false criteria: - - Playbooks tree view discovers and displays workspace playbooks + - Playbooks sidebar section discovers and displays workspace playbooks - Tree shows folder hierarchy, play names, and target hosts - id: PLB-002 @@ -478,7 +478,7 @@ stories: prd_refs: [US-7, AC-5] requires_ai: false criteria: - - EE tree view discovers and lists available container images + - EE sidebar section discovers and lists available container images - Images are discovered via ansible-navigator - id: EE-002 @@ -625,7 +625,7 @@ stories: prd_refs: [US-18, AC-8] requires_ai: true criteria: - - AI Tools tree view shows available MCP tools by category + - AI Tools sidebar section shows available MCP tools by category - Use in Chat action sends the tool prompt to the active chat - id: AI-009 @@ -651,7 +651,7 @@ stories: prd_refs: [US-18, AC-8] requires_ai: true criteria: - - AI Skills tree view shows available skills + - AI Skills sidebar section shows available skills - Skills are categorized by source (builtin, external) - Each skill shows a description of what it does @@ -746,14 +746,14 @@ stories: title: Extension activation and sidebar story: > As an Ansible developer, I want the extension to activate - cleanly and show the Ansible sidebar with all expected views, + cleanly and show Ansible in the Activity Bar, so that I can start working immediately after opening a project. prd_refs: [US-18, AC-8] requires_ai: false criteria: - Extension activates without errors - Ansible icon appears in the activity bar - - Clicking the icon opens the sidebar with tree views + - Clicking the icon opens Ansible (accordion sidebar) - id: XC-002 title: Graceful degradation without AI @@ -765,7 +765,7 @@ stories: prd_refs: [US-18, AC-8] requires_ai: false criteria: - - Core views populate and function without AI + - Core sidebar sections populate and function without AI - AI-only actions are hidden or show graceful fallback - No errors when enableAiFeatures is false @@ -779,7 +779,7 @@ stories: prd_refs: [US-19, AC-8] requires_ai: false criteria: - - Empty tree views show a helpful message with next-step actions + - Empty sidebar sections show a helpful message with next-step actions - Missing tool warnings explain what to install - Error states show actionable recovery guidance diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 6c108daf7f..a1a922c76d 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,9 +1,5 @@ -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { defineConfig } from '@vscode/test-cli'; -const rootDir = dirname(fileURLToPath(import.meta.url)); - export default defineConfig([ { label: 'default', @@ -14,7 +10,12 @@ export default defineConfig([ { label: 'no-python-envs', files: 'out/test/integration/no-envs/*.test.js', - launchArgs: [`--extensions-dir=${resolve(rootDir, '.vscode-test/no-envs-extensions')}`], + // Satisfy package.json extensionDependencies. @vscode/test-cli installs into + // the shared .vscode-test/extensions cache and does not honor a custom + // --extensions-dir in launchArgs, so isolation uses --disable-extension + // instead of an empty extensions dir (ADR-019 degraded mode). + installExtensions: ['ms-python.python', 'redhat.vscode-yaml'], + launchArgs: ['--disable-extension=ms-python.vscode-python-envs'], mocha: { timeout: 60000 }, }, ]); diff --git a/README.md b/README.md index 7b8b4d22b8..7e92be3555 100644 --- a/README.md +++ b/README.md @@ -22,64 +22,26 @@ src/ # Extension host (views, panels, commands) ## Features -### Sidebar Views +### Ansible (Activity Bar) -The extension adds an **Ansible** panel to the Activity Bar with seven tree views: +The extension adds an **Ansible** icon to the Activity Bar that opens the **Ansible** +sidebar — an accordion (one section open at a time, collapsed by default). Sections include: -#### Environment Managers +- **Environment Managers** — Python environments by manager; select, create, refresh +- **Ansible Dev Tools** — installed `ansible-dev-tools` packages; install / upgrade +- **Installed Collections** — collections → plugin types → plugins; docs and search +- **Collection Sources** — Galaxy and GitHub orgs; search and install +- **Execution Environments** — local EE images via `ansible-navigator`; expandable details +- **Creator** — `ansible-creator` init/add commands with schema-driven forms +- **Playbooks** — workspace discovery, configure, run +- **AI Tools / AI Skills / Lightspeed** — when the corresponding feature flags are enabled -- Lists all available Python environments grouped by manager type (venv, Global, Conda, etc.) -- Click an environment to set it as the active Python environment -- **+** button to create a new environment -- Auto-updates when the Python environment changes - -#### Ansible Dev Tools - -- Displays installed `ansible-dev-tools` packages and versions via `adt --version` -- **Install** / **Upgrade** / **Refresh** buttons -- Auto-updates when the Python environment changes - -#### Installed Collections - -- Lists installed Ansible collections alphabetically -- Expandable tree: Collection → Plugin Types → Plugins -- Click a plugin to open documentation in a webview panel -- **Search** plugins by keyword across all collections -- **Install** collections from Ansible Galaxy -- **AI Summary** for collections and plugins (when AI features enabled) -- Auto-updates when the Python environment changes - -#### Collection Sources - -- Browse Ansible collections from configurable GitHub organizations -- Default orgs: `ansible`, `ansible-collections`, `redhat-cop` -- **Add** custom GitHub organizations -- **Search** and **Install** collections directly from source -- Per-org refresh with cache persistence - -#### Execution Environments - -- Lists container EE images via `ansible-navigator images` -- Expandable details: Ansible version, OS, collections, Python packages -- **AI Summary** for EE inspection - -#### Creator - -- Tree view of `ansible-creator` commands (init, add) -- Click a leaf command to open a dynamic form built from `ansible-creator schema` -- Required/optional parameter sections with validation -- Run button executes the command in a terminal - -#### AI Tools - -- Lists all available MCP tools for AI agent integration -- Click a tool to inject a prompt into chat (Cursor/Copilot) -- Copy button for example prompts -- Visible when `ansibleEnvironments.enableAiFeatures` is enabled +Issue-driven open: when Python or ansible-dev-tools is missing, the Ansible sidebar suggests opening +the relevant section with welcome actions. ### Plugin Documentation Viewer -Click any plugin in the Collections tree to open a rich documentation webview: +Click any plugin in Installed Collections or Collection Sources to open a rich documentation webview: - **Synopsis** — description, requirements, author information - **Parameters** — collapsible tree with types, defaults, choices, and descriptions @@ -88,13 +50,6 @@ Click any plugin in the Collections tree to open a rich documentation webview: - **Return Values** — documented returns with samples - Configurable zoom (50–200%) and theme (auto/light/dark) -### Playbooks - -- Discovers playbooks in the workspace -- Per-playbook configuration (extra vars, inventory, etc.) -- **Run** in terminal or **Run with Progress Viewer** (real-time progress via custom Ansible callback plugin) -- **Edit** playbook or configuration directly from the tree - ## MCP Server The extension includes a standalone MCP server (`@ansible/mcp-server`) that exposes Ansible tools to AI agents. diff --git a/eslint.config.mjs b/eslint.config.mjs index e8de5eb960..da626a6a6a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -182,6 +182,7 @@ export default defineConfig( 'src/panels/bridges/**', 'src/panels/webview.d.ts', 'packages/ui/vite.config.ts', + 'packages/ui/playground/**', 'packages/lightspeed/webviews/**', 'packages/lightspeed/vite.config.mts', 'packages/lightspeed/vitest.config.mts', diff --git a/knip.json b/knip.json index ee32d7f6d6..0d0c8f5527 100644 --- a/knip.json +++ b/knip.json @@ -16,6 +16,7 @@ "project": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "scripts/**", "tools/**"], "ignoreDependencies": [ "@vscode-elements/elements", + "@vscode/codicons", "@modelcontextprotocol/sdk", "js-yaml", "zod", @@ -48,8 +49,12 @@ "ignoreDependencies": ["@ansible/developer-services", "electron", "vscode"] }, "packages/ui": { - "entry": ["src/bridge/index.ts"], - "project": ["src/**/*.{ts,tsx}"], + "entry": [ + "src/bridge/index.ts", + "src/sidebar/index.ts", + "playground/sidebar-navtree.tsx" + ], + "project": ["src/**/*.{ts,tsx}", "playground/**/*.{ts,tsx}"], "ignoreDependencies": ["react-dom", "@types/react-dom"] }, "packages/common": { diff --git a/media/walkthroughs/getting-started/sidebar.md b/media/walkthroughs/getting-started/sidebar.md index a273c6bae4..b3bbdecfa3 100644 --- a/media/walkthroughs/getting-started/sidebar.md +++ b/media/walkthroughs/getting-started/sidebar.md @@ -1,6 +1,8 @@ -# Ansible activity bar +# Ansible -The **Ansible** icon in the Activity Bar opens the sidebar for environments, -collections, playbooks, creator scaffolding, and related tools. +The **Ansible** icon in the Activity Bar opens the **Ansible** sidebar — an +accordion for environments, collections, playbooks, creator scaffolding, AI tools, +and related workflows. -Use it as the home base for day-to-day Ansible work in the editor. +Open one section at a time; use it as the home base for day-to-day Ansible work +in the editor. diff --git a/package.json b/package.json index bc0efa0352..c21045280e 100644 --- a/package.json +++ b/package.json @@ -255,83 +255,18 @@ "views": { "ansible-environments": [ { - "id": "ansibleDevToolsEnvManagers", - "name": "Environment Managers", - "type": "tree" - }, - { - "id": "ansibleDevToolsPackages", - "name": "Ansible Dev Tools", - "type": "tree" - }, - { - "id": "ansibleDevToolsCollections", - "name": "Installed Collections", - "type": "tree" - }, - { - "id": "ansibleCollectionSources", - "name": "Collection Sources", - "type": "tree" - }, - { - "id": "ansibleExecutionEnvironments", - "name": "Execution Environments", - "type": "tree" - }, - { - "id": "ansibleCreator", - "name": "Creator", - "type": "tree" - }, - { - "id": "ansiblePlaybooks", - "name": "Playbooks", - "type": "tree" - }, - { - "id": "ansibleMcpTools", - "name": "AI Tools", - "type": "tree", - "when": "config.ansibleEnvironments.enableAiFeatures" - }, - { - "id": "ansibleSkills", - "name": "AI Skills", - "type": "tree", - "when": "config.ansibleEnvironments.enableAiFeatures" - }, - { - "id": "ansibleLightspeed", - "name": "Lightspeed", - "type": "tree", - "when": "config.ansible.lightspeed.enabled" + "id": "ansibleNavTree", + "name": "Ansible", + "type": "webview" } ] }, - "viewsWelcome": [ - { - "view": "ansibleDevToolsEnvManagers", - "contents": "No Python extension detected.\n[Install Python Extension](command:workbench.extensions.search?%22ms-python.python%22)", - "when": "!ansible.pythonEnvAvailable" - }, - { - "view": "ansibleDevToolsEnvManagers", - "contents": "Environment management uses terminal fallbacks.\nFor the best experience, install the Python Environments extension.\n[Install Python Environments](command:workbench.extensions.search?%22ms-python.vscode-python-envs%22)\n[Create Virtual Environment](command:ansibleDevToolsEnvManagers.create)", - "when": "ansible.pythonEnvCapability == python-only" - }, + "commands": [ { - "view": "ansibleDevToolsPackages", - "contents": "No ansible-dev-tools packages found.\n[Install ansible-dev-tools](command:ansibleDevToolsPackages.install)", - "when": "!ansibleDevToolsPackages.hasPackages && ansible.pythonEnvAvailable" + "command": "ansible.sidebar.navTree.open", + "category": "Ansible", + "title": "Open Ansible" }, - { - "view": "ansibleDevToolsPackages", - "contents": "No Python extension detected.\n[Install Python Extension](command:workbench.extensions.search?%22ms-python.python%22)", - "when": "!ansible.pythonEnvAvailable" - } - ], - "commands": [ { "command": "ansibleEnvironments.vault", "category": "Ansible", @@ -778,255 +713,6 @@ "command": "ansible.lightspeed.statusBar.click", "when": "false" } - ], - "view/title": [ - { - "command": "ansibleDevToolsEnvManagers.create", - "when": "view == ansibleDevToolsEnvManagers", - "group": "navigation@1" - }, - { - "command": "ansibleDevToolsEnvManagers.refresh", - "when": "view == ansibleDevToolsEnvManagers", - "group": "navigation@2" - }, - { - "command": "ansibleDevToolsPackages.install", - "when": "view == ansibleDevToolsPackages", - "group": "navigation@1" - }, - { - "command": "ansibleDevToolsPackages.upgrade", - "when": "view == ansibleDevToolsPackages", - "group": "navigation@2" - }, - { - "command": "ansibleDevToolsPackages.refresh", - "when": "view == ansibleDevToolsPackages", - "group": "navigation@3" - }, - { - "command": "ansibleDevToolsCollections.search", - "when": "view == ansibleDevToolsCollections", - "group": "navigation@1" - }, - { - "command": "ansibleDevToolsCollections.refresh", - "when": "view == ansibleDevToolsCollections", - "group": "navigation@2" - }, - { - "command": "ansibleDevToolsCollections.aiSummary", - "when": "view == ansibleDevToolsCollections && config.ansibleEnvironments.enableAiFeatures", - "group": "navigation@3" - }, - { - "command": "ansibleExecutionEnvironments.buildFromDefinition", - "when": "view == ansibleExecutionEnvironments", - "group": "navigation@1" - }, - { - "command": "ansibleExecutionEnvironments.aiSummary", - "when": "view == ansibleExecutionEnvironments && config.ansibleEnvironments.enableAiFeatures", - "group": "navigation@2" - }, - { - "command": "ansibleExecutionEnvironments.refresh", - "when": "view == ansibleExecutionEnvironments", - "group": "navigation@3" - }, - { - "command": "ansibleCreator.aiSummary", - "when": "view == ansibleCreator && config.ansibleEnvironments.enableAiFeatures", - "group": "navigation@1" - }, - { - "command": "ansibleCreator.refresh", - "when": "view == ansibleCreator", - "group": "navigation@2" - }, - { - "command": "ansibleMcpTools.refresh", - "when": "view == ansibleMcpTools", - "group": "navigation@1" - }, - { - "command": "ansibleSkills.refresh", - "when": "view == ansibleSkills", - "group": "navigation@1" - }, - { - "command": "ansiblePlaybooks.editDefaults", - "when": "view == ansiblePlaybooks", - "group": "navigation@1" - }, - { - "command": "ansiblePlaybooks.refresh", - "when": "view == ansiblePlaybooks", - "group": "navigation@2" - }, - { - "command": "ansibleCollectionSources.search", - "when": "view == ansibleCollectionSources", - "group": "navigation@1" - }, - { - "command": "ansibleCollectionSources.install", - "when": "view == ansibleCollectionSources", - "group": "navigation@2" - }, - { - "command": "ansibleCollectionSources.addSource", - "when": "view == ansibleCollectionSources", - "group": "navigation@3" - }, - { - "command": "ansibleCollectionSources.refresh", - "when": "view == ansibleCollectionSources", - "group": "navigation@4" - }, - { - "command": "ansibleCollectionSources.aiSummary", - "when": "view == ansibleCollectionSources && config.ansibleEnvironments.enableAiFeatures", - "group": "navigation@5" - } - ], - "view/item/context": [ - { - "command": "ansibleDevToolsCollections.aiCollectionSummary", - "when": "view == ansibleDevToolsCollections && viewItem == collection && config.ansibleEnvironments.enableAiFeatures", - "group": "inline" - }, - { - "command": "ansibleDevToolsCollections.showPluginDoc", - "when": "view == ansibleDevToolsCollections && viewItem == plugin", - "group": "inline@1" - }, - { - "command": "ansibleDevToolsCollections.aiPluginSummary", - "when": "view == ansibleDevToolsCollections && viewItem == plugin && config.ansibleEnvironments.enableAiFeatures", - "group": "inline@2" - }, - { - "command": "ansibleExecutionEnvironments.aiEESummary", - "when": "view == ansibleExecutionEnvironments && viewItem == executionEnvironment && config.ansibleEnvironments.enableAiFeatures", - "group": "inline" - }, - { - "command": "ansibleCreator.openForm", - "when": "view == ansibleCreator && viewItem == creatorCommand", - "group": "inline@1" - }, - { - "command": "ansibleCreator.aiEntrySummary", - "when": "view == ansibleCreator && viewItem == creatorCommand && config.ansibleEnvironments.enableAiFeatures", - "group": "inline@2" - }, - { - "command": "ansiblePlaybooks.run", - "when": "view == ansiblePlaybooks && viewItem == playbook", - "group": "inline@1" - }, - { - "command": "ansiblePlaybooks.runWithProgress", - "when": "view == ansiblePlaybooks && viewItem == playbook", - "group": "inline@2" - }, - { - "command": "ansiblePlaybooks.openPlaybook", - "when": "view == ansiblePlaybooks && viewItem == playbook", - "group": "inline@3" - }, - { - "command": "ansiblePlaybooks.editConfig", - "when": "view == ansiblePlaybooks && viewItem == playbook", - "group": "inline@4" - }, - { - "command": "ansiblePlaybooks.aiSummary", - "when": "view == ansiblePlaybooks && viewItem == playbook && config.ansibleEnvironments.enableAiFeatures", - "group": "inline@5" - }, - { - "command": "ansibleMcpTools.copyPrompt", - "when": "view == ansibleMcpTools && viewItem == mcpTool", - "group": "inline" - }, - { - "command": "ansibleSkills.useInChat", - "when": "view == ansibleSkills && viewItem == skill", - "group": "inline" - }, - { - "command": "ansibleCollectionSources.filterGalaxyCollections", - "when": "view == ansibleCollectionSources && viewItem == collectionSourceGalaxy", - "group": "inline@1" - }, - { - "command": "ansibleCollectionSources.refreshSource", - "when": "view == ansibleCollectionSources && viewItem == collectionSourceGalaxy", - "group": "inline@2" - }, - { - "command": "ansibleCollectionSources.aiSourceSummary", - "when": "view == ansibleCollectionSources && viewItem == collectionSourceGalaxy && config.ansibleEnvironments.enableAiFeatures", - "group": "inline@3" - }, - { - "command": "ansibleCollectionSources.filterGalaxyCollections", - "when": "view == ansibleCollectionSources && viewItem == collectionSourceGalaxyFiltered", - "group": "inline@1" - }, - { - "command": "ansibleCollectionSources.clearGalaxyFilter", - "when": "view == ansibleCollectionSources && viewItem == collectionSourceGalaxyFiltered", - "group": "inline@2" - }, - { - "command": "ansibleCollectionSources.refreshSource", - "when": "view == ansibleCollectionSources && viewItem == collectionSourceGalaxyFiltered", - "group": "inline@3" - }, - { - "command": "ansibleCollectionSources.installFromSource", - "when": "view == ansibleCollectionSources && viewItem == collectionSourceGitHub", - "group": "inline@1" - }, - { - "command": "ansibleCollectionSources.searchSource", - "when": "view == ansibleCollectionSources && viewItem == collectionSourceGitHub", - "group": "inline@2" - }, - { - "command": "ansibleCollectionSources.refreshSource", - "when": "view == ansibleCollectionSources && viewItem == collectionSourceGitHub", - "group": "inline@3" - }, - { - "command": "ansibleCollectionSources.aiSourceSummary", - "when": "view == ansibleCollectionSources && viewItem == collectionSourceGitHub && config.ansibleEnvironments.enableAiFeatures", - "group": "inline@4" - }, - { - "command": "ansibleCollectionSources.installGalaxyCollection", - "when": "view == ansibleCollectionSources && viewItem == galaxyCollection", - "group": "inline@1" - }, - { - "command": "ansibleCollectionSources.refreshGitHubCollection", - "when": "view == ansibleCollectionSources && viewItem == githubCollection", - "group": "inline@1" - }, - { - "command": "ansibleCollectionSources.galaxyPluginAiSummary", - "when": "view == ansibleCollectionSources && viewItem == galaxyPlugin && config.ansibleEnvironments.enableAiFeatures", - "group": "inline@1" - }, - { - "command": "ansibleCollectionSources.githubPluginAiSummary", - "when": "view == ansibleCollectionSources && viewItem == githubPlugin && config.ansibleEnvironments.enableAiFeatures", - "group": "inline@1" - } ] }, "configuration": { @@ -1035,7 +721,7 @@ "ansibleEnvironments.enableAiFeatures": { "type": "boolean", "default": true, - "description": "Enable AI enhancements (AI Tools view, sparkle icons, AI prompt buttons)" + "description": "Enable AI enhancements (AI Tools accordion in the Ansible sidebar, sparkle icons, AI prompt buttons)" }, "ansibleEnvironments.skillSources": { "type": "array", @@ -1218,7 +904,7 @@ { "id": "open-ansible-sidebar", "title": "Open the Ansible activity bar", - "description": "Click the Ansible icon in the Activity Bar to browse environments, collections, playbooks, and scaffolding tools.\n[Open Ansible view](command:workbench.view.extension.ansible-environments)", + "description": "Click the Ansible icon in the Activity Bar to open Ansible and browse environments, collections, playbooks, and scaffolding tools in the accordion.\n[Open Ansible](command:workbench.view.extension.ansible-environments)", "media": { "markdown": "media/walkthroughs/getting-started/sidebar.md" }, @@ -1229,7 +915,7 @@ { "id": "create-environment", "title": "Create a Python environment", - "description": "Create an isolated environment so Ansible development tools stay out of your system Python, then install ansible-dev-tools from the Dev Tools view.\n[Create Environment](command:ansibleDevToolsEnvManagers.create)\n[Install ansible-dev-tools](command:ansibleDevToolsPackages.install)", + "description": "Create an isolated environment so Ansible development tools stay out of your system Python, then install ansible-dev-tools from the Ansible Dev Tools accordion in the Ansible sidebar.\n[Create Environment](command:ansibleDevToolsEnvManagers.create)\n[Install ansible-dev-tools](command:ansibleDevToolsPackages.install)", "media": { "markdown": "media/walkthroughs/getting-started/environment.md" }, @@ -1248,7 +934,7 @@ { "id": "browse-collections", "title": "Browse collections and plugin docs", - "description": "Use Installed Collections to explore plugins and open documentation in the plugin doc panel without leaving the editor.\n[Refresh Collections](command:ansibleDevToolsCollections.refresh)\n[Search Plugins](command:ansibleDevToolsCollections.search)", + "description": "Use the Installed Collections accordion in the Ansible sidebar to explore plugins and open documentation in the plugin doc panel without leaving the editor.\n[Refresh Collections](command:ansibleDevToolsCollections.refresh)\n[Search Plugins](command:ansibleDevToolsCollections.search)", "media": { "markdown": "media/walkthroughs/getting-started/collections.md" } @@ -1256,7 +942,7 @@ { "id": "collection-sources", "title": "Find and install collections", - "description": "Search Galaxy or GitHub from Collection Sources, preview docs for content you have not installed yet, then install with ADE.\n[Refresh Collection Sources](command:ansibleCollectionSources.refresh)\n[Install Collection](command:ansibleDevToolsCollections.install)", + "description": "Search Galaxy or GitHub from the Collection Sources accordion in the Ansible sidebar, preview docs for content you have not installed yet, then install with ADE.\n[Refresh Collection Sources](command:ansibleCollectionSources.refresh)\n[Install Collection](command:ansibleDevToolsCollections.install)", "media": { "markdown": "media/walkthroughs/getting-started/collection-sources.md" } @@ -1264,7 +950,7 @@ { "id": "scaffold-content", "title": "Scaffold content with Creator", - "description": "Open the Creator view, pick a schema-driven form, review the live CLI preview, and scaffold a project, collection, or role.\n[Refresh Creator](command:ansibleCreator.refresh)", + "description": "Open the Creator accordion in the Ansible sidebar, pick a schema-driven form, review the live CLI preview, and scaffold a project, collection, or role.\n[Refresh Creator](command:ansibleCreator.refresh)", "media": { "markdown": "media/walkthroughs/getting-started/creator.md" } @@ -1272,7 +958,7 @@ { "id": "run-playbooks", "title": "Run playbooks with progress", - "description": "Discover playbooks in the workspace, adjust run config, then run in a terminal or open the real-time progress viewer.\n[Refresh Playbooks](command:ansiblePlaybooks.refresh)", + "description": "Discover playbooks in the workspace from the Playbooks accordion in the Ansible sidebar, adjust run config, then run in a terminal or open the real-time progress viewer.\n[Refresh Playbooks](command:ansiblePlaybooks.refresh)", "media": { "markdown": "media/walkthroughs/getting-started/playbooks.md" } @@ -1280,7 +966,7 @@ { "id": "execution-environments", "title": "Inspect execution environments", - "description": "Browse local EE images, open image details, and inspect packages when you develop against containers.\n[Refresh Execution Environments](command:ansibleExecutionEnvironments.refresh)", + "description": "Browse local EE images from the Execution Environments accordion in the Ansible sidebar, open image details, and inspect packages when you develop against containers.\n[Refresh Execution Environments](command:ansibleExecutionEnvironments.refresh)", "media": { "markdown": "media/walkthroughs/getting-started/execution-environments.md" } @@ -1389,6 +1075,7 @@ "@redhat-developer/vscode-redhat-telemetry": "^0.10.2", "@types/js-yaml": "^4.0.9", "@vscode-elements/elements": "^1.6.0", + "@vscode/codicons": "^0.0.36", "@vscode/python-extension": "~1.0.5", "ini": "^6.0.0", "js-yaml": "^4.1.1", diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 1a97c30fae..b1136aeb54 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -52,7 +52,7 @@ export type { ToxAvailability, ToxGhMatrixEntry, } from './types'; -export { TelemetryEvents, buildOutcomeProperties } from './types'; +export { TelemetryEvents, buildOutcomeProperties, shortExecutionEnvironmentName } from './types'; // --- Utils --- export { @@ -110,3 +110,16 @@ export { buildPlaybookSummaryPrompt, } from './prompts'; export type { CollectionSourcesInput, TaskAnalysisInput } from './prompts'; + +// --- Sidebar NavTree (ADR-025) --- +export type { + SidebarSectionId, + SidebarNodeAction, + SidebarNodeExpand, + SidebarTreeNode, + SidebarSectionSeverity, + SidebarWelcomeAction, + SidebarSection, + SidebarSnapshot, + SidebarEnvManagerInput, +} from './sidebar'; diff --git a/packages/common/src/sidebar.ts b/packages/common/src/sidebar.ts new file mode 100644 index 0000000000..3587eafab9 --- /dev/null +++ b/packages/common/src/sidebar.ts @@ -0,0 +1,128 @@ +/** + * Host-agnostic sidebar NavTree snapshot (ADR-025). + * Consumed by @ansible/ui and produced by SidebarModel / host adapters. + */ + +export type SidebarSectionId = + | 'envManagers' + | 'devTools' + | 'collections' + | 'collectionSources' + | 'executionEnvironments' + | 'creator' + | 'playbooks' + | 'aiTools' + | 'aiSkills' + | 'lightspeed'; + +/** Inline row action (`view/item/context` group:inline equivalent). */ +export interface SidebarNodeAction { + id: string; + label: string; + /** Codicon name without $(…), e.g. sparkle, play, book. */ + icon: string; + command: string; + /** Serializable args for the host command (not node ids). */ + args?: unknown[]; +} + +/** + * Host payload for lazy tree expansion (e.g. Galaxy collection → plugin types). + * The React shell posts this back; it does not fetch docs itself. + */ +export type SidebarNodeExpand = + | { + kind: 'galaxyCollection'; + namespace: string; + name: string; + version: string; + } + | { + kind: 'githubCollection'; + org: string; + namespace: string; + name: string; + version: string; + repository: string; + } + | { + kind: 'eeDetail'; + fullName: string; + }; + +export interface SidebarTreeNode { + id: string; + label: string; + description?: string; + /** + * Hover text matching native TreeItem.tooltip (plain text; newlines allowed). + * Rendered by the NavTree webview on mouse-over. + */ + tooltip?: string; + icon?: string; + warning?: boolean; + selected?: boolean; + actions?: SidebarNodeAction[]; + children?: SidebarTreeNode[]; + /** + * When true, the row is expandable before `children` are known. + * First expand asks the host to resolve {@link expand}. + */ + lazyChildren?: boolean; + /** Opaque-to-UI payload for host-side lazy expansion. */ + expand?: SidebarNodeExpand; +} + +export type SidebarSectionSeverity = 'none' | 'info' | 'warning' | 'error'; + +/** Welcome-view action (`viewsWelcome` markdown link equivalent). */ +export interface SidebarWelcomeAction { + id: string; + label: string; + command: string; + /** Optional command arguments (e.g. extension search query). */ + args?: unknown[]; +} + +export interface SidebarSection { + id: SidebarSectionId; + title: string; + badge?: string; + severity?: SidebarSectionSeverity; + welcome?: string; + welcomeActions?: SidebarWelcomeAction[]; + /** + * Title-bar actions shown on header hover + * (`menus["view/title"]` navigation group equivalent). + */ + headerActions?: SidebarNodeAction[]; + /** True while the host is still hydrating this section's body. */ + loading?: boolean; + nodes: SidebarTreeNode[]; +} + +export interface SidebarSnapshot { + sections: SidebarSection[]; + /** + * When set (non-null), the shell should open this section (issue-driven). + * `null` / omitted means "do not change" the user's accordion selection + * on progressive updates — only apply when the host has a suggestion. + */ + suggestedOpenSectionId?: SidebarSectionId | null; +} + +/** Plain env-manager input for SidebarModel (no vscode types). */ +export interface SidebarEnvManagerInput { + id: string; + name: string; + isGlobal: boolean; + environments: { + id: string; + label: string; + version?: string; + /** sysPrefix or displayPath for native env tooltip. */ + path?: string; + selected: boolean; + warning?: boolean; + }[]; +} diff --git a/packages/common/src/types/execution-env.ts b/packages/common/src/types/execution-env.ts index 3da612ee05..016936f71d 100644 --- a/packages/common/src/types/execution-env.ts +++ b/packages/common/src/types/execution-env.ts @@ -2,6 +2,21 @@ * Types for execution environment management. */ +/** + * Short display label for an EE image ref: last path segment including tag. + * `ghcr.io/ansible/community-ansible-dev-tools:devel` → `community-ansible-dev-tools:devel` + * @param fullName - Full image reference (registry path and optional tag) + * @returns Last path segment including tag, or the trimmed input when there is no slash + */ +export function shortExecutionEnvironmentName(fullName: string): string { + const trimmed = fullName.trim(); + if (!trimmed) { + return fullName; + } + const slash = trimmed.lastIndexOf('/'); + return slash >= 0 ? trimmed.slice(slash + 1) : trimmed; +} + /** Information about an execution environment container image. */ export interface ExecutionEnvironment { created: string; diff --git a/packages/common/src/types/index.ts b/packages/common/src/types/index.ts index 6014c16a66..9bf130a57b 100644 --- a/packages/common/src/types/index.ts +++ b/packages/common/src/types/index.ts @@ -21,6 +21,7 @@ export type { PythonPackageDetail, SystemPackageDetail, } from './execution-env'; +export { shortExecutionEnvironmentName } from './execution-env'; export type { ContainerEngine, ContainerImage, InspectedImage } from './container'; export type { CacheIndex, CacheIndexEntry } from './ee-cache'; export type { GalaxyCollection } from './galaxy'; diff --git a/packages/common/test/types/execution-env.test.ts b/packages/common/test/types/execution-env.test.ts new file mode 100644 index 0000000000..0282603ca1 --- /dev/null +++ b/packages/common/test/types/execution-env.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { shortExecutionEnvironmentName } from '../../src/types/execution-env'; + +describe('shortExecutionEnvironmentName', () => { + it('keeps name:tag when there is no registry path', () => { + expect(shortExecutionEnvironmentName('ee:latest')).toBe('ee:latest'); + }); + + it('strips registry and namespace, keeping the final name:tag', () => { + expect( + shortExecutionEnvironmentName('ghcr.io/ansible/community-ansible-dev-tools:devel'), + ).toBe('community-ansible-dev-tools:devel'); + }); + + it('handles localhost image refs', () => { + expect(shortExecutionEnvironmentName('localhost/ansible-mcp-server:latest')).toBe( + 'ansible-mcp-server:latest', + ); + }); + + it('trims whitespace', () => { + expect(shortExecutionEnvironmentName(' quay.io/org/image:1 ')).toBe('image:1'); + }); + + it('returns the original value for empty input', () => { + expect(shortExecutionEnvironmentName('')).toBe(''); + expect(shortExecutionEnvironmentName(' ')).toBe(' '); + }); +}); diff --git a/packages/lightspeed/src/activate.ts b/packages/lightspeed/src/activate.ts index 14f7b02060..f491b05e16 100644 --- a/packages/lightspeed/src/activate.ts +++ b/packages/lightspeed/src/activate.ts @@ -8,7 +8,6 @@ import { } from './definitions'; import { getUserTypeLabel, ANSIBLE_LIGHTSPEED_AUTH_ID } from './utils/webUtils'; import type { TelemetryReporter } from './telemetry'; -import { LightspeedViewProvider } from './views/lightspeedView'; import { registerGenerationCommands } from './commands/generation'; import { registerExplanationCommands } from './commands/explanation'; import { registerInlineSuggestions } from './commands/inlineSuggestions'; @@ -75,7 +74,6 @@ export async function activate( log('error', `Session expired: ${e instanceof Error ? e.message : String(e)}`); const expiredSessionId = currentSession.id; currentSession = undefined; - viewProvider.refresh(false); updateStatusBar(); await authProvider.removeSession(expiredSessionId); log('info', 'Expired session removed from storage'); @@ -97,10 +95,7 @@ export async function activate( const api = new LightspeedAPI(apiConfig); - const viewProvider = new LightspeedViewProvider(); - context.subscriptions.push( - vscode.window.registerTreeDataProvider('ansibleLightspeed', viewProvider), - ); + // Lightspeed Activity Bar tree removed — features live in the Ansible sidebar (ADR-025). const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); statusBar.command = LightspeedCommands.LIGHTSPEED_STATUS_BAR_CLICK; @@ -136,7 +131,6 @@ export async function activate( { createIfNone: true }, ); log('info', `Sign-in successful: ${currentSession.account.label}`); - viewProvider.refresh(!!currentSession); updateStatusBar(); } catch (e) { vscode.window.showErrorMessage( @@ -198,7 +192,6 @@ export async function activate( currentSession = undefined; log('debug', 'Session refresh: no session found'); } - viewProvider.refresh(!!currentSession); updateStatusBar(); } @@ -214,7 +207,6 @@ export async function activate( account: { label: session.accountLabel, id: session.accountId }, scopes: [], }; - viewProvider.refresh(true); updateStatusBar(); }, ), diff --git a/packages/lightspeed/src/views/lightspeedView.ts b/packages/lightspeed/src/views/lightspeedView.ts deleted file mode 100644 index baf5a42c76..0000000000 --- a/packages/lightspeed/src/views/lightspeedView.ts +++ /dev/null @@ -1,101 +0,0 @@ -import * as vscode from 'vscode'; -import { LightspeedCommands } from '../definitions'; - -/** - * A tree item representing a single entry in the Lightspeed sidebar view. - */ -class LightspeedTreeItem extends vscode.TreeItem { - /** - * Creates a new Lightspeed tree item. - * @param label - The display text for the tree item - * @param command - The command to execute when the item is clicked - * @param icon - The icon to display beside the label - */ - constructor(label: string, command?: vscode.Command, icon?: vscode.ThemeIcon) { - super(label, vscode.TreeItemCollapsibleState.None); - this.command = command; - this.iconPath = icon; - } -} - -/** - * Provides tree data for the Lightspeed sidebar view. - */ -export class LightspeedViewProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - private _isAuthenticated = false; - - /** - * Refreshes the tree view based on the current authentication state. - * @param isAuthenticated - Whether the user is currently authenticated - */ - refresh(isAuthenticated: boolean) { - this._isAuthenticated = isAuthenticated; - this._onDidChangeTreeData.fire(undefined); - } - - /** - * Returns the tree item representation for the given element. - * @param element - The tree item to return - * @returns The tree item itself - */ - getTreeItem(element: LightspeedTreeItem): LightspeedTreeItem { - return element; - } - - /** - * Returns the child tree items based on the authentication state. - * @returns An array of tree items for the Lightspeed sidebar - */ - getChildren(): LightspeedTreeItem[] { - if (!this._isAuthenticated) { - return [ - new LightspeedTreeItem( - 'Sign in to Ansible Lightspeed', - { - command: LightspeedCommands.LIGHTSPEED_AUTH_REQUEST, - title: 'Sign in', - }, - new vscode.ThemeIcon('sign-in'), - ), - ]; - } - - return [ - new LightspeedTreeItem( - 'Generate Playbook', - { - command: LightspeedCommands.LIGHTSPEED_PLAYBOOK_GENERATION, - title: 'Generate Playbook', - }, - new vscode.ThemeIcon('wand'), - ), - new LightspeedTreeItem( - 'Generate Role', - { - command: LightspeedCommands.LIGHTSPEED_ROLE_GENERATION, - title: 'Generate Role', - }, - new vscode.ThemeIcon('wand'), - ), - new LightspeedTreeItem( - 'Explain Playbook', - { - command: LightspeedCommands.LIGHTSPEED_PLAYBOOK_EXPLANATION, - title: 'Explain Playbook', - }, - new vscode.ThemeIcon('book'), - ), - new LightspeedTreeItem( - 'Explain Role', - { - command: LightspeedCommands.LIGHTSPEED_ROLE_EXPLANATION, - title: 'Explain Role', - }, - new vscode.ThemeIcon('book'), - ), - ]; - } -} diff --git a/packages/lightspeed/test/unit/lightspeedView.test.ts b/packages/lightspeed/test/unit/lightspeedView.test.ts deleted file mode 100644 index 3641cbb72e..0000000000 --- a/packages/lightspeed/test/unit/lightspeedView.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('vscode', () => ({ - EventEmitter: class MockEventEmitter { - private listeners: ((value: T) => void)[] = []; - event = (listener: (value: T) => void) => { - this.listeners.push(listener); - return { dispose: () => {} }; - }; - fire(value: T) { - for (const listener of this.listeners) listener(value); - } - dispose() {} - }, - TreeItem: class MockTreeItem { - label: string; - collapsibleState: number; - command?: unknown; - iconPath?: unknown; - constructor(label: string, collapsibleState: number) { - this.label = label; - this.collapsibleState = collapsibleState; - } - }, - TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, - ThemeIcon: class MockThemeIcon { - constructor(public id: string) {} - }, -})); - -import { LightspeedViewProvider } from '../../src/views/lightspeedView'; - -describe('LightspeedViewProvider', () => { - let provider: LightspeedViewProvider; - - beforeEach(() => { - provider = new LightspeedViewProvider(); - }); - - describe('getChildren', () => { - it('shows sign-in item when not authenticated', () => { - const children = provider.getChildren(); - - expect(children).toHaveLength(1); - expect(children[0].label).toBe('Sign in to Ansible Lightspeed'); - expect(children[0].command?.command).toBe('ansible.lightspeed.oauth'); - }); - - it('shows feature items when authenticated', () => { - provider.refresh(true); - const children = provider.getChildren(); - - expect(children).toHaveLength(4); - expect(children[0].label).toBe('Generate Playbook'); - expect(children[1].label).toBe('Generate Role'); - expect(children[2].label).toBe('Explain Playbook'); - expect(children[3].label).toBe('Explain Role'); - }); - - it('reverts to sign-in when session is cleared', () => { - provider.refresh(true); - expect(provider.getChildren()).toHaveLength(4); - - provider.refresh(false); - const children = provider.getChildren(); - expect(children).toHaveLength(1); - expect(children[0].label).toBe('Sign in to Ansible Lightspeed'); - }); - }); - - describe('getTreeItem', () => { - it('returns the element as-is', () => { - const children = provider.getChildren(); - const item = provider.getTreeItem(children[0]); - expect(item).toBe(children[0]); - }); - }); - - describe('onDidChangeTreeData', () => { - it('fires when refresh is called', () => { - const listener = vi.fn(); - provider.onDidChangeTreeData(listener); - - provider.refresh(true); - - expect(listener).toHaveBeenCalledWith(undefined); - }); - }); -}); diff --git a/packages/services/src/CollectionsService.ts b/packages/services/src/CollectionsService.ts index 05c3556505..dd789ca822 100644 --- a/packages/services/src/CollectionsService.ts +++ b/packages/services/src/CollectionsService.ts @@ -482,9 +482,11 @@ export class CollectionsService { this._log('Starting refresh'); - // Try to load from cache first for instant UI + // Try to load from cache first for instant UI. + // Treat an empty cache as a miss — e.g. indexed before ansible-doc / + // ansible-dev-tools was available in the selected env. const cache = readCollectionsCache(); - if (cache) { + if (cache && cache.collections.length > 0) { this._log( `Cache found with ${String(cache.collections.length)} collections from ${cache.timestamp}`, ); @@ -499,7 +501,11 @@ export class CollectionsService { return; } - this._log('No cache found, doing full load'); + if (cache?.collections.length === 0) { + this._log('Cache is empty — ignoring and doing full load'); + } else { + this._log('No cache found, doing full load'); + } // No cache - do full load with loading state this._loading = true; diff --git a/packages/services/src/CommandService.ts b/packages/services/src/CommandService.ts index 609bdaaf7c..8b66e36e26 100644 --- a/packages/services/src/CommandService.ts +++ b/packages/services/src/CommandService.ts @@ -93,14 +93,16 @@ export class CommandService { /** * Get the full path to a tool (e.g., 'ade', 'ansible-doc'). - * Checks venv first, then cached environment, then PATH. + * Prefers the active environment bin dir (ADR-004). When a bin dir is + * known, missing tools return null — do not leak ~/.local/bin or a + * stale cache from a previously selected environment. * * @param toolName - Executable name to resolve. * @param workspaceUri - Optional workspace URI for bin dir resolution. * @returns Absolute tool path, or null when the executable is not found. */ public async getToolPath(toolName: string, workspaceUri?: unknown): Promise { - // Try to get from current venv + // Active / cached bin dir from the selected environment const binDir = await this.getBinDir(workspaceUri); if (binDir) { const toolPath = path.join(binDir, toolName); @@ -108,17 +110,19 @@ export class CommandService { log(`CommandService: ${toolName} found in binDir -> ${toolPath}`); return toolPath; } - log(`CommandService: ${toolName} not in binDir ${binDir}`); + // Env is selected but tool is not installed there — do not fall + // through to PATH (e.g. ~/.local/bin) or another env's cache. + log(`CommandService: ${toolName} not in active binDir ${binDir}`); + return null; } - // Try cached environment + // No active bin dir — last resort for standalone / pre-init const cachedPath = getCachedToolPath(toolName); if (cachedPath) { log(`CommandService: ${toolName} found in cache -> ${cachedPath}`); return cachedPath; } - // Fall back to PATH search const pathResult = await findExecutableWithCache(toolName); log(`CommandService: ${toolName} PATH fallback -> ${pathResult ?? 'not found'}`); return pathResult; diff --git a/packages/services/src/DevToolsService.ts b/packages/services/src/DevToolsService.ts index ba0fd9ad90..be2e32462f 100644 --- a/packages/services/src/DevToolsService.ts +++ b/packages/services/src/DevToolsService.ts @@ -170,19 +170,78 @@ export class DevToolsService { } /** - * Install ansible-dev-tools package. + * Install ansible-dev-tools package (ADR-019). * - * Uses the python-envs managePackages API when available (Layer 2). - * Falls back to `pip install ansible-dev-tools` in an activated - * terminal when only ms-python.python is present (Layer 3). + * Order: + * 1. Deterministic `{selectedPython} -m pip` via CommandService (works right + * after a venv switch; does not hang on managePackages UI). + * 2. Layer 2 managePackages callback (with timeout). + * 3. Activated-terminal pip as last resort. */ public async install(): Promise { + const python = await this._resolveSelectedPython(); + if (python) { + log(`DevToolsService: installing via pip exec: ${python}`); + try { + await this._installViaPipExec(python); + return; + } catch (error) { + log( + `DevToolsService: Layer 1 pip exec failed, trying fallbacks: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if (this._packageInstaller) { - await this._packageInstaller(); - await this.refresh(); - return; + try { + await Promise.race([ + this._packageInstaller(), + new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new Error('Layer 2 install timed out after 30s')); + }, 30_000); + }), + ]); + await this.refresh(); + return; + } catch (error) { + log( + `DevToolsService: Layer 2 install failed, trying terminal fallback: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + await this._installViaTerminal(); + } + + /** + * Non-interactive pip install into a known interpreter (preferred after env select). + * @param python - Absolute path to the selected environment's python + */ + private async _installViaPipExec(python: string): Promise { + const { getCommandService } = await import('./CommandService'); + const result = await getCommandService().runCommandArgs( + python, + ['-m', 'pip', 'install', 'ansible-dev-tools'], + { timeout: 600_000 }, + ); + if (result.exitCode !== 0) { + throw new Error( + result.stderr || + result.stdout || + `pip install ansible-dev-tools failed (exit ${String(result.exitCode)})`, + ); + } + log('DevToolsService: pip exec install complete, refreshing packages'); + await this.refresh(); + } + + /** + * Layer 3: pip install ansible-dev-tools in an activated terminal. + * Prefers `{selectedPython} -m pip` so install targets the chosen venv + * even when the terminal is not yet activated. + */ + private async _installViaTerminal(): Promise { if (!this.isInVSCode()) { throw new Error('install is only available in VS Code'); } @@ -193,13 +252,16 @@ export class DevToolsService { ); } + const pipCommand = await this._resolvePipInstallCommand(); + log(`DevToolsService: terminal install via: ${pipCommand}`); + const TerminalService = DevToolsService.terminalServiceFactory(); const terminalService = TerminalService.getInstance(); const managed = await terminalService.createActivatedTerminal({ name: 'Install ansible-dev-tools', show: true, }); - const result = await managed.sendCommand('pip install ansible-dev-tools', { + const result = await managed.sendCommand(pipCommand, { waitForCompletion: true, }); log('DevToolsService: terminal install complete, refreshing packages'); @@ -220,6 +282,46 @@ export class DevToolsService { } } + /** + * Resolve the selected environment's Python executable. + * @returns Absolute python path, or undefined when none is known + */ + private async _resolveSelectedPython(): Promise { + const { getCommandService } = await import('./CommandService'); + const { getCachedEnvironment } = await import('./EnvironmentCache'); + const fs = await import('fs'); + const binDir = await getCommandService().getBinDir(); + if (binDir) { + const candidates = + process.platform === 'win32' + ? [path.join(binDir, 'python.exe')] + : [path.join(binDir, 'python3'), path.join(binDir, 'python')]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + const cached = getCachedEnvironment()?.pythonPath; + if (cached && fs.existsSync(cached)) { + return cached; + } + return undefined; + } + + /** + * Build a pip install command pinned to the selected environment's Python. + * @returns Shell command string for ansible-dev-tools install + */ + private async _resolvePipInstallCommand(): Promise { + const python = await this._resolveSelectedPython(); + if (python) { + const quoted = python.includes(' ') ? `"${python}"` : python; + return `${quoted} -m pip install ansible-dev-tools`; + } + return 'pip install ansible-dev-tools'; + } + /** * Upgrade ansible-dev-tools package with eager strategy (VS Code only) */ diff --git a/packages/services/src/SidebarModel.ts b/packages/services/src/SidebarModel.ts new file mode 100644 index 0000000000..b1dd90689d --- /dev/null +++ b/packages/services/src/SidebarModel.ts @@ -0,0 +1,21 @@ +/** + * Stable import path for the sidebar NavTree model (ADR-025). + * Implementation lives under `./sidebar/`. + */ +export { SidebarModel } from './sidebar/SidebarModel'; +export type { + SidebarAiToolInput, + SidebarLightspeedItem, + SidebarCollectionSourceInput, + SidebarCreatorInput, + SidebarPlaybookWorkspaceInput, + SidebarModelInput, + SidebarSkeletonOptions, +} from './sidebar/types'; +export { SECTION_REGISTRY } from './sidebar/registry'; +export type { SidebarSectionDef } from './sidebar/registry'; +export { + assembleSidebarInput, + assembleCollectionSources, + assembleAiTools, +} from './sidebar/assembleSidebarInput'; diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts index fb407a8d42..cd32228297 100644 --- a/packages/services/src/index.ts +++ b/packages/services/src/index.ts @@ -97,6 +97,14 @@ export type { ToxRunResult, ToxAvailability, ToxGhMatrixEntry, + SidebarSectionId, + SidebarNodeAction, + SidebarTreeNode, + SidebarSectionSeverity, + SidebarWelcomeAction, + SidebarSection, + SidebarSnapshot, + SidebarEnvManagerInput, } from '@ansible/common'; // --- Services --- @@ -139,6 +147,23 @@ export { SkillRegistry, _resetGitHubToken } from './SkillRegistry'; export { discoverPlaybooks } from './PlaybookDiscovery'; export { ToxAnsibleService } from './ToxAnsibleService'; export type { DiscoveredPlaybook } from './PlaybookDiscovery'; +export { SidebarModel, SECTION_REGISTRY } from './SidebarModel'; +export type { + SidebarModelInput, + SidebarCollectionSourceInput, + SidebarCreatorInput, + SidebarPlaybookWorkspaceInput, + SidebarAiToolInput, + SidebarLightspeedItem, + SidebarSkeletonOptions, + SidebarSectionDef, +} from './SidebarModel'; +export { + assembleSidebarInput, + assembleCollectionSources, + assembleAiTools, +} from './sidebar/assembleSidebarInput'; +export { preserveExpandedChildren } from './sidebar/preserveExpanded'; // VS Code-specific type re-export export type * from './pythonEnvApi'; diff --git a/packages/services/src/sidebar/SidebarModel.ts b/packages/services/src/sidebar/SidebarModel.ts new file mode 100644 index 0000000000..337a6052c1 --- /dev/null +++ b/packages/services/src/sidebar/SidebarModel.ts @@ -0,0 +1,155 @@ +/** + * Host-agnostic sidebar NavTree model (ADR-025). + * Thin orchestrator over {@link SECTION_REGISTRY}. + */ +import type { + EEDetails, + PluginInfo, + SidebarSectionId, + SidebarSnapshot, + SidebarTreeNode, + SystemPackageDetail, +} from '@ansible/common'; +import { loadingNode } from './helpers'; +import { SECTION_REGISTRY } from './registry'; +import { buildEeDetailNodes, buildPluginTypeNodes, patchNodeChildren } from './expand'; +import type { SidebarModelInput, SidebarSkeletonOptions } from './types'; + +export type { + SidebarAiToolInput, + SidebarLightspeedItem, + SidebarCollectionSourceInput, + SidebarCreatorInput, + SidebarPlaybookWorkspaceInput, + SidebarModelInput, + SidebarSkeletonOptions, +} from './types'; + +/** + * Builds serializable snapshots for VS Code WebviewView / Electron. + */ +export class SidebarModel { + /** + * Build a full sidebar snapshot from plain service inputs. + * @param input - Environment, tools, collections, sources, EE, creator, playbooks + * @returns Snapshot for the React shell + */ + public buildSnapshot(input: SidebarModelInput): SidebarSnapshot { + const sections = SECTION_REGISTRY.filter((def) => def.include?.(input) ?? true).map((def) => + def.build(input), + ); + return { + sections, + suggestedOpenSectionId: this.suggestOpenSection(input), + }; + } + + /** + * Header-only snapshot for progressive hydration (paint before async work). + * @param options - Which optional AI / Lightspeed sections to include + * @returns Skeleton snapshot (no issue-driven open) + */ + public buildSkeletonSnapshot(options: SidebarSkeletonOptions = {}): SidebarSnapshot { + const gate: SidebarModelInput = { + pythonAvailable: true, + enableAiFeatures: options.enableAiFeatures ?? false, + envManagers: [], + devTools: [], + hasDevTools: false, + collections: [], + playbooks: [], + lightspeedEnabled: options.lightspeedEnabled ?? false, + }; + const sections = SECTION_REGISTRY.filter((def) => def.include?.(gate) ?? true).map( + (def) => ({ + id: def.id, + title: def.title, + loading: true as const, + nodes: [loadingNode(def.id)], + }), + ); + return { + sections, + suggestedOpenSectionId: null, + }; + } + + /** + * Choose which section should auto-open for a known issue. + * @param input - Same inputs as buildSnapshot + * @returns Section id to open, or null when healthy + */ + public suggestOpenSection(input: SidebarModelInput): SidebarSectionId | null { + for (const def of SECTION_REGISTRY) { + if (!(def.include?.(input) ?? true)) { + continue; + } + if (def.suggest?.(input)) { + return def.id; + } + } + return null; + } + + /** + * Build EE detail categories (Info / Collections / Python / System). + * @param parentId - EE node id + * @param fullName - Image full name + * @param details - Loaded EE details, or null on failure + * @param systemPackages - System packages for the image + * @returns Category nodes with package leaves + */ + public buildEeDetailNodes( + parentId: string, + fullName: string, + details: EEDetails | null, + systemPackages: Pick[], + ): SidebarTreeNode[] { + return buildEeDetailNodes(parentId, fullName, details, systemPackages); + } + + /** + * Build plugin-type folders with plugin leaves for Galaxy / GitHub sources. + * @param parentId - Parent collection node id + * @param pluginTypes - Plugins grouped by type, or null on fetch failure + * @param collection - Serializable collection identity for doc commands + * @param collection.namespace - Collection namespace + * @param collection.name - Collection name + * @param collection.version - Collection version string + * @param collection.org - Optional GitHub org (SCM sources) + * @param collection.repository - Optional GitHub repository (SCM sources) + * @param source - Galaxy vs GitHub + * @param enableAiFeatures - When true, add Describe with AI + * @returns Child nodes for the collection row + */ + public buildPluginTypeNodes( + parentId: string, + pluginTypes: Record | null, + collection: { + namespace: string; + name: string; + version: string; + org?: string; + repository?: string; + }, + source: 'galaxy' | 'github', + enableAiFeatures: boolean, + ): SidebarTreeNode[] { + return buildPluginTypeNodes(parentId, pluginTypes, collection, source, enableAiFeatures); + } + + /** + * Replace one node's children and clear lazyChildren in a copied snapshot. + * @param snapshot - Current snapshot + * @param nodeId - Node to update + * @param children - Resolved children + * @returns New snapshot + */ + public patchNodeChildren( + snapshot: SidebarSnapshot, + nodeId: string, + children: SidebarTreeNode[], + ): SidebarSnapshot { + return patchNodeChildren(snapshot, nodeId, children); + } +} diff --git a/packages/services/src/sidebar/assembleSidebarInput.ts b/packages/services/src/sidebar/assembleSidebarInput.ts new file mode 100644 index 0000000000..33cbf98d08 --- /dev/null +++ b/packages/services/src/sidebar/assembleSidebarInput.ts @@ -0,0 +1,214 @@ +/** + * Pure assembly of {@link SidebarModelInput} from already-fetched plain data. + * No vscode / Node service singletons — host gathers, this shapes. + */ +import type { + CollectionData, + CreatorStatus, + ExecutionEnvironment, + SchemaNode, + SidebarEnvManagerInput, + SkillEntry, + SkillSource, +} from '@ansible/common'; +import type { DevToolPackage } from '../DevToolsService'; +import type { DiscoveredPlaybook } from '../PlaybookDiscovery'; +import type { + SidebarAiToolInput, + SidebarCollectionSourceInput, + SidebarModelInput, + SidebarPlaybookWorkspaceInput, +} from './types'; + +/** Galaxy collection row for NavTree source shaping. */ +export interface AssembleGalaxyCollection { + namespace: string; + name: string; + version?: string; + downloadCount?: number; + deprecated?: boolean; +} + +/** GitHub collection row for NavTree source shaping. */ +export interface AssembleGitHubCollection { + namespace: string; + name: string; + version?: string; + org: string; + repository: string; + description?: string; + htmlUrl?: string; + installUrl?: string; +} + +/** One GitHub org block for collection sources. */ +export interface AssembleGitHubOrg { + org: string; + count: number; + isRefreshing?: boolean; + /** Pre-formatted locale string for tooltips. */ + lastUpdated?: string; + collections: AssembleGitHubCollection[]; +} + +/** MCP tool row before label truncation / category labels. */ +export interface AssembleMcpTool { + category: string; + tool: { name: string; description: string }; + examplePrompt: string; + /** Full tool payload retained for useInChat. */ + toolInfo: unknown; +} + +/** Inputs gathered by the host before pure assembly. */ +export interface AssembleSidebarInputArgs { + pythonAvailable: boolean; + pythonEnvCapability?: SidebarModelInput['pythonEnvCapability']; + enableAiFeatures: boolean; + envManagers: SidebarEnvManagerInput[]; + devTools: DevToolPackage[]; + hasDevTools: boolean; + collections: CollectionData[]; + collectionsIndexing?: boolean; + galaxy: { + totalCount: number; + isLoading: boolean; + filter?: string; + listed: AssembleGalaxyCollection[]; + }; + githubOrgs: AssembleGitHubOrg[]; + executionEnvironments: ExecutionEnvironment[]; + executionEnvironmentsLoading?: boolean; + executionEnvironmentsError?: string; + creator: { + status: CreatorStatus; + installedVersion?: string; + schema: SchemaNode | null; + }; + playbookWorkspaces: SidebarPlaybookWorkspaceInput[]; + mcpConfigured?: boolean; + mcpIdeLabel?: string; + mcpCategoryLabels?: Record; + mcpTools?: AssembleMcpTool[]; + skillSources?: SkillSource[]; + skills?: SkillEntry[]; + lightspeedEnabled?: boolean; + lightspeedAuthenticated?: boolean; +} + +/** + * Shape Galaxy + GitHub cache rows into hub collection-source inputs. + * @param galaxy - Galaxy list / filter slice + * @param githubOrgs - Per-org collection lists + * @returns Serializable collectionSources for SidebarModel + */ +export function assembleCollectionSources( + galaxy: AssembleSidebarInputArgs['galaxy'], + githubOrgs: AssembleGitHubOrg[], +): SidebarCollectionSourceInput[] { + return [ + { + id: 'galaxy', + name: 'Ansible Galaxy', + type: 'galaxy', + count: galaxy.totalCount, + isRefreshing: galaxy.isLoading, + galaxyFilter: galaxy.filter, + galaxyFilterResultCount: galaxy.filter ? galaxy.listed.length : undefined, + topCollections: galaxy.listed.map((c) => ({ + name: `${c.namespace}.${c.name}`, + version: c.version, + namespace: c.namespace, + collectionName: c.name, + downloadCount: c.downloadCount, + deprecated: c.deprecated, + })), + }, + ...githubOrgs.map((org) => ({ + id: org.org, + name: org.org, + type: 'github' as const, + count: org.count, + isRefreshing: org.isRefreshing, + lastUpdated: org.lastUpdated, + topCollections: org.collections + .slice() + .sort((a, b) => + `${a.namespace}.${a.name}`.localeCompare(`${b.namespace}.${b.name}`), + ) + .map((c) => ({ + name: `${c.namespace}.${c.name}`, + version: c.version, + namespace: c.namespace, + collectionName: c.name, + org: c.org, + repository: c.repository, + description: c.description, + htmlUrl: c.htmlUrl, + installUrl: c.installUrl, + })), + })), + ]; +} + +/** + * Map MCP tool rows into NavTree AI Tools inputs. + * @param tools - Tools from the MCP controller + * @param categoryLabels - Display labels by category id + * @returns Ai tool inputs, or undefined when empty + */ +export function assembleAiTools( + tools: AssembleMcpTool[] | undefined, + categoryLabels: Record, +): SidebarAiToolInput[] | undefined { + if (!tools?.length) { + return undefined; + } + return tools.map((t) => { + const firstLine = t.tool.description.split('\n')[0]?.trim() || t.tool.name; + const label = firstLine.length > 60 ? `${firstLine.substring(0, 57)}...` : firstLine; + return { + category: t.category, + categoryLabel: categoryLabels[t.category] ?? t.category, + name: t.tool.name, + label, + examplePrompt: t.examplePrompt, + toolInfo: t.toolInfo, + }; + }); +} + +/** + * Build a complete {@link SidebarModelInput} from host-gathered plain data. + * @param args - Fetched service/cache values + * @returns Input for SidebarModel.buildSnapshot + */ +export function assembleSidebarInput(args: AssembleSidebarInputArgs): SidebarModelInput { + const playbooks: DiscoveredPlaybook[] = args.playbookWorkspaces.flatMap((ws) => ws.playbooks); + return { + pythonAvailable: args.pythonAvailable, + pythonEnvCapability: args.pythonEnvCapability, + enableAiFeatures: args.enableAiFeatures, + envManagers: args.envManagers, + devTools: args.devTools, + hasDevTools: args.hasDevTools, + collections: args.collectionsIndexing ? [] : args.collections, + collectionsIndexing: args.collectionsIndexing, + collectionSources: assembleCollectionSources(args.galaxy, args.githubOrgs), + executionEnvironments: args.executionEnvironmentsError ? [] : args.executionEnvironments, + executionEnvironmentsLoading: args.executionEnvironmentsLoading, + executionEnvironmentsError: args.executionEnvironmentsError, + creator: args.creator, + playbooks, + playbookWorkspaces: args.playbookWorkspaces, + aiTools: args.enableAiFeatures + ? assembleAiTools(args.mcpTools, args.mcpCategoryLabels ?? {}) + : undefined, + mcpConfigured: args.mcpConfigured, + mcpIdeLabel: args.mcpIdeLabel, + skillSources: args.skillSources, + skills: args.skills, + lightspeedEnabled: args.lightspeedEnabled, + lightspeedAuthenticated: args.lightspeedAuthenticated, + }; +} diff --git a/packages/services/src/sidebar/expand.ts b/packages/services/src/sidebar/expand.ts new file mode 100644 index 0000000000..7e1364c2dd --- /dev/null +++ b/packages/services/src/sidebar/expand.ts @@ -0,0 +1,283 @@ +/** + * Lazy-expand node builders for the sidebar NavTree (pure / browser-safe inputs). + */ +import type { + EEDetails, + PluginInfo, + SidebarSnapshot, + SidebarTreeNode, + SystemPackageDetail, +} from '@ansible/common'; + +/** + * Build EE detail categories (Info / Collections / Python / System). + * Host loads details via ExecutionEnvService; this stays browser-safe. + * @param parentId - EE node id + * @param fullName - Image full name (for package-detail command args) + * @param details - Loaded EE details, or null on failure + * @param systemPackages - System packages for the image + * @returns Category nodes with package leaves + */ +export function buildEeDetailNodes( + parentId: string, + fullName: string, + details: EEDetails | null, + systemPackages: Pick[], +): SidebarTreeNode[] { + if (!details) { + return [ + { + id: `${parentId}-error`, + label: 'Failed to load details', + icon: 'warning', + }, + ]; + } + const categories: SidebarTreeNode[] = []; + const infoChildren: SidebarTreeNode[] = []; + if (details.ansible_version?.details) { + infoChildren.push({ + id: `${parentId}-info-ansible`, + label: 'Ansible', + description: details.ansible_version.details, + icon: 'info', + }); + } + const osDetails = details.os_release?.details; + if (osDetails?.[0]) { + const os = osDetails[0]; + infoChildren.push({ + id: `${parentId}-info-os`, + label: 'OS', + description: os['pretty-name'] ?? os.name ?? 'Unknown', + icon: 'info', + }); + } + if (details.image_name) { + infoChildren.push({ + id: `${parentId}-info-image`, + label: 'Image', + description: details.image_name, + icon: 'info', + }); + } + if (infoChildren.length > 0) { + categories.push({ + id: `${parentId}-info`, + label: 'Info', + icon: 'info', + children: infoChildren, + }); + } + if (details.ansible_collections?.details) { + const cols = Object.entries(details.ansible_collections.details).sort(([a], [b]) => + a.localeCompare(b), + ); + if (cols.length > 0) { + categories.push({ + id: `${parentId}-collections`, + label: 'Ansible Collections', + description: `(${String(cols.length)})`, + icon: 'library', + children: cols.map(([name, version]) => ({ + id: `${parentId}-col-${name}`, + label: name, + description: version, + icon: 'library', + })), + }); + } + } + if (details.python_packages?.details) { + const pkgs = [...details.python_packages.details].sort((a, b) => + a.name.localeCompare(b.name), + ); + if (pkgs.length > 0) { + categories.push({ + id: `${parentId}-python`, + label: 'Python Packages', + description: `(${String(pkgs.length)})`, + icon: 'package', + children: pkgs.map((pkg) => ({ + id: `${parentId}-py-${pkg.name}`, + label: pkg.name, + description: pkg.version, + tooltip: pkg.summary ?? undefined, + icon: 'package', + actions: [ + { + id: `py-detail-${pkg.name}`, + label: 'Show Package Detail', + icon: 'info', + command: 'ansibleExecutionEnvironments.showPackageDetail', + args: [fullName, pkg.name, 'python'], + }, + ], + })), + }); + } + } + if (systemPackages.length > 0) { + categories.push({ + id: `${parentId}-system`, + label: 'System Packages', + description: `(${String(systemPackages.length)})`, + icon: 'server', + children: systemPackages.map((pkg) => ({ + id: `${parentId}-sys-${pkg.name}`, + label: pkg.name, + description: pkg.version, + icon: 'server-process', + actions: [ + { + id: `sys-detail-${pkg.name}`, + label: 'Show Package Detail', + icon: 'info', + command: 'ansibleExecutionEnvironments.showPackageDetail', + args: [fullName, pkg.name, 'system'], + }, + ], + })), + }); + } + return categories.length > 0 + ? categories + : [ + { + id: `${parentId}-empty`, + label: 'No details available', + icon: 'info', + }, + ]; +} + +/** + * Build plugin-type folders with plugin leaves for Galaxy / GitHub sources. + * @param parentId - Parent collection node id + * @param pluginTypes - Plugins grouped by type, or null on fetch failure + * @param collection - Serializable collection identity for doc commands + * @param collection.namespace - Collection namespace + * @param collection.name - Collection name + * @param collection.version - Collection version string + * @param collection.org - Optional GitHub org (SCM sources) + * @param collection.repository - Optional GitHub repository (SCM sources) + * @param source - Galaxy vs GitHub (selects open-doc / AI commands) + * @param enableAiFeatures - When true, add Describe with AI + * @returns Child nodes for the collection row + */ +export function buildPluginTypeNodes( + parentId: string, + pluginTypes: Record | null, + collection: { + namespace: string; + name: string; + version: string; + org?: string; + repository?: string; + }, + source: 'galaxy' | 'github', + enableAiFeatures: boolean, +): SidebarTreeNode[] { + if (!pluginTypes) { + const errorTooltip = + source === 'galaxy' + ? `Could not fetch docs-blob for ${collection.namespace}.${collection.name} v${collection.version}. Click the collection to retry.` + : `Could not index ${collection.namespace}.${collection.name}. Requires git and ansible-doc on PATH. Click to retry.`; + return [ + { + id: `${parentId}-error`, + label: 'Failed to load plugin documentation', + tooltip: errorTooltip, + icon: 'warning', + }, + ]; + } + const docCommand = + source === 'galaxy' + ? 'ansibleCollectionSources.showGalaxyPluginDoc' + : 'ansibleCollectionSources.showGitHubPluginDoc'; + const aiCommand = + source === 'galaxy' + ? 'ansibleCollectionSources.galaxyPluginAiSummary' + : 'ansibleCollectionSources.githubPluginAiSummary'; + return Object.entries(pluginTypes) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([pluginType, plugins]) => ({ + id: `${parentId}-type-${pluginType}`, + label: pluginType, + description: `(${String(plugins.length)})`, + icon: 'symbol-folder', + children: plugins.map((plugin) => { + const nodeArg = { collection, plugin, pluginType }; + const tip: string[] = [`${plugin.fullName} (${pluginType})`]; + if (plugin.shortDescription) { + tip.push('', plugin.shortDescription); + } + tip.push( + '', + `Collection: ${collection.namespace}.${collection.name} v${collection.version}`, + ); + if (source === 'github' && collection.org && collection.repository) { + tip.push(`Source: ${collection.org}/${collection.repository}`); + } + return { + id: `${parentId}-plugin-${pluginType}-${plugin.fullName}`, + label: plugin.name, + description: plugin.shortDescription, + tooltip: tip.join('\n'), + icon: 'symbol-method', + actions: [ + { + id: `doc-${plugin.fullName}`, + label: 'Show Plugin Documentation', + icon: 'book', + command: docCommand, + args: [nodeArg], + }, + ...(enableAiFeatures + ? [ + { + id: `ai-${plugin.fullName}`, + label: 'Describe with AI', + icon: 'sparkle', + command: aiCommand, + args: [nodeArg], + }, + ] + : []), + ], + }; + }), + })); +} + +/** + * Replace one node's children and clear lazyChildren in a copied snapshot. + * @param snapshot - Current snapshot + * @param nodeId - Node to update + * @param children - Resolved children + * @returns New snapshot + */ +export function patchNodeChildren( + snapshot: SidebarSnapshot, + nodeId: string, + children: SidebarTreeNode[], +): SidebarSnapshot { + const patch = (nodes: SidebarTreeNode[]): SidebarTreeNode[] => + nodes.map((n) => { + if (n.id === nodeId) { + return { ...n, lazyChildren: false, children }; + } + if (n.children?.length) { + return { ...n, children: patch(n.children) }; + } + return n; + }); + return { + ...snapshot, + sections: snapshot.sections.map((s) => ({ + ...s, + nodes: patch(s.nodes), + })), + }; +} diff --git a/packages/services/src/sidebar/helpers.ts b/packages/services/src/sidebar/helpers.ts new file mode 100644 index 0000000000..768ffda340 --- /dev/null +++ b/packages/services/src/sidebar/helpers.ts @@ -0,0 +1,17 @@ +/** + * Shared sidebar section helpers. + */ +import type { SidebarSectionId, SidebarTreeNode } from '@ansible/common'; + +/** + * Loading row shown in skeleton section bodies. + * @param sectionId - Section that owns the loading row + * @returns Tree node with a spinning loading icon + */ +export function loadingNode(sectionId: SidebarSectionId): SidebarTreeNode { + return { + id: `${sectionId}-loading`, + label: 'Loading…', + icon: 'loading', + }; +} diff --git a/packages/services/src/sidebar/preserveExpanded.ts b/packages/services/src/sidebar/preserveExpanded.ts new file mode 100644 index 0000000000..da46408b87 --- /dev/null +++ b/packages/services/src/sidebar/preserveExpanded.ts @@ -0,0 +1,53 @@ +import type { SidebarSnapshot, SidebarTreeNode } from '@ansible/common'; + +/** + * Preserve previously expanded lazy collection/EE children across full snapshot rebuilds. + * @param previous - Last posted snapshot (or undefined on first paint) + * @param fresh - Newly built snapshot + * @returns Snapshot with prior expanded children restored where still applicable + */ +export function preserveExpandedChildren( + previous: SidebarSnapshot | undefined, + fresh: SidebarSnapshot, +): SidebarSnapshot { + if (!previous) { + return fresh; + } + const expanded = new Map(); + const collect = (nodes: SidebarTreeNode[]): void => { + for (const n of nodes) { + if (!n.lazyChildren && n.children?.length && n.expand) { + expanded.set(n.id, n.children); + } + if (n.children?.length) { + collect(n.children); + } + } + }; + for (const section of previous.sections) { + collect(section.nodes); + } + if (expanded.size === 0) { + return fresh; + } + + const restore = (nodes: SidebarTreeNode[]): SidebarTreeNode[] => + nodes.map((n) => { + const prior = expanded.get(n.id); + if (prior && n.lazyChildren) { + return { ...n, lazyChildren: false, children: prior }; + } + if (n.children?.length) { + return { ...n, children: restore(n.children) }; + } + return n; + }); + + return { + ...fresh, + sections: fresh.sections.map((s) => ({ + ...s, + nodes: restore(s.nodes), + })), + }; +} diff --git a/packages/services/src/sidebar/registry.ts b/packages/services/src/sidebar/registry.ts new file mode 100644 index 0000000000..b4267facd9 --- /dev/null +++ b/packages/services/src/sidebar/registry.ts @@ -0,0 +1,86 @@ +/** + * Ordered registry of Ansible NavTree accordion sections (ADR-025). + * Skeleton and full snapshots share this list for id/title/order. + */ +import type { SidebarSection, SidebarSectionId } from '@ansible/common'; +import type { SidebarModelInput } from './types'; +import { buildEnvManagers, shouldSuggestEnvManagers } from './sections/envManagers'; +import { buildDevTools, shouldSuggestDevTools } from './sections/devTools'; +import { buildCollections } from './sections/collections'; +import { buildCollectionSources } from './sections/collectionSources'; +import { buildExecutionEnvironments } from './sections/executionEnvironments'; +import { buildCreator } from './sections/creator'; +import { buildPlaybooks } from './sections/playbooks'; +import { buildAiTools } from './sections/aiTools'; +import { buildAiSkills } from './sections/aiSkills'; +import { buildLightspeed } from './sections/lightspeed'; + +/** Definition of one NavTree section for snapshot / skeleton / suggest-open. */ +export interface SidebarSectionDef { + id: SidebarSectionId; + title: string; + /** Include in snapshot when true; omit means always include. */ + include?: (input: SidebarModelInput) => boolean; + build: (input: SidebarModelInput) => SidebarSection; + /** First matching suggest in registry order wins. */ + suggest?: (input: SidebarModelInput) => boolean; +} + +/** Canonical section order for Ansible NavTree. */ +export const SECTION_REGISTRY: readonly SidebarSectionDef[] = [ + { + id: 'envManagers', + title: 'Environment Managers', + build: buildEnvManagers, + suggest: shouldSuggestEnvManagers, + }, + { + id: 'devTools', + title: 'Ansible Dev Tools', + build: buildDevTools, + suggest: shouldSuggestDevTools, + }, + { + id: 'collections', + title: 'Installed Collections', + build: buildCollections, + }, + { + id: 'collectionSources', + title: 'Collection Sources', + build: buildCollectionSources, + }, + { + id: 'executionEnvironments', + title: 'Execution Environments', + build: buildExecutionEnvironments, + }, + { + id: 'creator', + title: 'Creator', + build: buildCreator, + }, + { + id: 'playbooks', + title: 'Playbooks', + build: buildPlaybooks, + }, + { + id: 'aiTools', + title: 'AI Tools', + include: (input) => input.enableAiFeatures, + build: buildAiTools, + }, + { + id: 'aiSkills', + title: 'AI Skills', + include: (input) => input.enableAiFeatures, + build: buildAiSkills, + }, + { + id: 'lightspeed', + title: 'Lightspeed', + include: (input) => Boolean(input.lightspeedEnabled), + build: buildLightspeed, + }, +]; diff --git a/packages/services/src/sidebar/sections/aiSkills.ts b/packages/services/src/sidebar/sections/aiSkills.ts new file mode 100644 index 0000000000..01fab68935 --- /dev/null +++ b/packages/services/src/sidebar/sections/aiSkills.ts @@ -0,0 +1,177 @@ +/** + * AI Skills NavTree section. + */ +import type { + SidebarNodeAction, + SidebarSection, + SidebarTreeNode, + SkillEntry, +} from '@ansible/common'; +import type { SidebarModelInput } from '../types'; + +/** + * Build AI Skills (sources → modules → skills). + * @param input - Model inputs + * @returns Section snapshot + */ +export function buildAiSkills(input: SidebarModelInput): SidebarSection { + const sources = input.skillSources ?? []; + const skills = input.skills ?? []; + const trustIcon: Record = { + certified: 'verified', + community: 'globe', + partner: 'organization', + private: 'lock', + }; + const headerActions: SidebarNodeAction[] = [ + { + id: 'skills-refresh', + label: 'Refresh', + icon: 'refresh', + command: 'ansibleSkills.refresh', + }, + ]; + if (sources.length === 0) { + return { + id: 'aiSkills', + title: 'AI Skills', + headerActions, + nodes: [ + { + id: 'skills-empty', + label: 'No skill sources configured', + icon: 'warning', + warning: true, + }, + ], + }; + } + // Native SkillsController: empty registry → single root message (not source list) + if (skills.length === 0) { + return { + id: 'aiSkills', + title: 'AI Skills', + headerActions, + nodes: [ + { + id: 'skills-none', + label: 'No skills available', + icon: 'info', + }, + ], + }; + } + const nodes: SidebarTreeNode[] = sources.map((source) => { + const sourceSkills = skills.filter((s) => s.source === source.id); + const modules = new Map(); + for (const skill of sourceSkills) { + const list = modules.get(skill.module) ?? []; + list.push(skill); + modules.set(skill.module, list); + } + let children: SidebarTreeNode[]; + if (sourceSkills.length === 0) { + // Native: "No skills loaded. Browse available skills at {url}" + const hint = source.url + ? `Browse available skills at ${source.url}` + : 'Configure access in Settings.'; + children = [ + { + id: `skill-empty-${source.id}`, + label: `No skills loaded. ${hint}`, + icon: 'info', + }, + ]; + } else if (modules.size <= 1) { + children = sourceSkills.map((skill) => ({ + id: `skill-${skill.id}`, + label: skill.name, + // Native SkillNode uses category as description + description: skill.category, + tooltip: formatSkillTooltip(skill), + icon: 'mortar-board', + actions: [ + { + id: `skill-use-${skill.id}`, + label: 'Use in Chat', + icon: 'sparkle', + command: 'ansibleSkills.useInChat', + args: [skill], + }, + ], + })); + } else { + children = [...modules.entries()].map(([mod, modSkills]) => ({ + id: `skill-mod-${source.id}-${mod}`, + label: mod, + description: `${String(modSkills.length)} skills`, + icon: 'package', + children: modSkills.map((skill) => ({ + id: `skill-${skill.id}`, + label: skill.name, + description: skill.category, + tooltip: formatSkillTooltip(skill), + icon: 'mortar-board', + actions: [ + { + id: `skill-use-${skill.id}`, + label: 'Use in Chat', + icon: 'sparkle', + command: 'ansibleSkills.useInChat', + args: [skill], + }, + ], + })), + })); + } + return { + id: `skill-src-${source.id}`, + label: source.id, + description: + sourceSkills.length > 0 + ? `${source.trust} · ${String(sourceSkills.length)} skills` + : `${source.trust} · no skills loaded`, + tooltip: [ + source.id, + '', + `Type: ${source.type}`, + `Trust: ${source.trust}`, + `URL: ${source.url}`, + `Skills: ${String(sourceSkills.length)}`, + ].join('\n'), + icon: trustIcon[source.trust] ?? 'globe', + children, + }; + }); + return { + id: 'aiSkills', + title: 'AI Skills', + headerActions, + nodes, + }; +} + +/** + * Plain-text hover content for a skill leaf (native SkillNode tooltip). + * @param skill - Indexed skill + * @returns Multiline tooltip + */ +function formatSkillTooltip(skill: SkillEntry): string { + const lines = [ + skill.name, + '', + skill.description, + '', + `Source: ${skill.source} (${skill.trust})`, + `Module: ${skill.module}`, + `Category: ${skill.category}`, + ]; + if (skill.domain) { + lines.push(`Domain: ${skill.domain}`); + } + if (skill.triggers.length > 0) { + lines.push(`Triggers: ${skill.triggers.join(', ')}`); + } + lines.push('', `skill_get({ skill_id: "${skill.id}" })`); + return lines.join('\n'); +} diff --git a/packages/services/src/sidebar/sections/aiTools.ts b/packages/services/src/sidebar/sections/aiTools.ts new file mode 100644 index 0000000000..bc0962ebbf --- /dev/null +++ b/packages/services/src/sidebar/sections/aiTools.ts @@ -0,0 +1,138 @@ +/** + * AI Tools NavTree section. + */ +import type { SidebarSection, SidebarTreeNode } from '@ansible/common'; +import type { SidebarAiToolInput, SidebarModelInput } from '../types'; + +/** + * Build AI Tools (MCP tool categories) when AI features are enabled. + * @param input - Model inputs + * @returns Section snapshot + */ +export function buildAiTools(input: SidebarModelInput): SidebarSection { + const tools = input.aiTools ?? []; + const iconMap: Record = { + getting_started: 'rocket', + discovery: 'search', + generation: 'code', + execution: 'package', + devtools: 'tools', + creator: 'wand', + }; + const byCategory = new Map(); + for (const tool of tools) { + const list = byCategory.get(tool.category) ?? []; + list.push(tool); + byCategory.set(tool.category, list); + } + const categoryNodes: SidebarTreeNode[] = [...byCategory.entries()].map( + ([category, catTools]) => ({ + id: `ai-cat-${category}`, + label: catTools[0]?.categoryLabel ?? category, + description: `${String(catTools.length)} ${catTools.length === 1 ? 'tool' : 'tools'}`, + icon: iconMap[category] ?? 'symbol-misc', + children: catTools + .slice() + .sort((a, b) => a.label.localeCompare(b.label)) + .map((t) => ({ + id: `ai-tool-${t.name}`, + label: t.label, + tooltip: formatAiToolTooltip(t), + icon: 'sparkle', + actions: [ + { + id: `ai-use-${t.name}`, + label: 'Use in Chat', + icon: 'comment', + command: 'ansibleMcpTools.useInChat', + args: [t.toolInfo], + }, + { + id: `ai-copy-${t.name}`, + label: 'Copy Prompt', + icon: 'copy', + command: 'ansibleMcpTools.copyPrompt', + // Handler expects { toolInfo: ToolInfo } + args: [{ toolInfo: t.toolInfo }], + }, + ], + })), + }), + ); + const nodes: SidebarTreeNode[] = []; + if (input.mcpConfigured === false) { + const ide = input.mcpIdeLabel ?? 'this IDE'; + nodes.push({ + id: 'mcp-warning', + label: `MCP not configured for ${ide}`, + description: 'click to configure', + tooltip: `AI tools require MCP to be configured.\n\nClick to configure for ${ide}.`, + icon: 'warning', + warning: true, + actions: [ + { + id: 'mcp-configure', + label: 'Configure MCP', + icon: 'gear', + command: 'ansibleMcpTools.configure', + }, + ], + }); + } + nodes.push(...categoryNodes); + return { + id: 'aiTools', + title: 'AI Tools', + headerActions: [ + { + id: 'ai-tools-refresh', + label: 'Refresh', + icon: 'refresh', + command: 'ansibleMcpTools.refresh', + }, + ], + nodes, + }; +} + +/** + * Plain-text hover content for an MCP tool row (native McpToolsController tooltip). + * @param tool - AI tool input row + * @returns Multiline tooltip + */ +function formatAiToolTooltip(tool: SidebarAiToolInput): string { + const info = tool.toolInfo as + | { + tool?: { + name?: string; + description?: string; + inputSchema?: { + properties?: Record; + required?: string[]; + }; + }; + examplePrompt?: string; + } + | undefined; + const def = info?.tool; + const lines: string[] = [def?.name ?? tool.name, '']; + if (def?.description) { + lines.push(def.description, ''); + } + const inputSchema = def?.inputSchema; + const props = inputSchema?.properties; + if (props && Object.keys(props).length > 0) { + const required = new Set(inputSchema.required ?? []); + lines.push('Parameters:'); + for (const [name, schema] of Object.entries(props)) { + const reqMark = required.has(name) ? ' (required)' : ''; + lines.push(`- ${name}${reqMark}: ${schema.description ?? schema.type ?? 'any'}`); + } + lines.push(''); + } + const example = info?.examplePrompt ?? tool.examplePrompt; + if (example) { + lines.push('Example prompt:', example); + } + return lines.join('\n').trim(); +} diff --git a/packages/services/src/sidebar/sections/collectionSources.ts b/packages/services/src/sidebar/sections/collectionSources.ts new file mode 100644 index 0000000000..7c9966219f --- /dev/null +++ b/packages/services/src/sidebar/sections/collectionSources.ts @@ -0,0 +1,241 @@ +/** + * Collection Sources NavTree section. + */ +import type { SidebarNodeAction, SidebarSection, SidebarTreeNode } from '@ansible/common'; +import type { SidebarModelInput } from '../types'; + +/** + * Build Collection Sources (Galaxy + GitHub orgs) with native header/inline actions. + * @param input - Model inputs + * @returns Section snapshot + */ +export function buildCollectionSources(input: SidebarModelInput): SidebarSection { + const sources = input.collectionSources ?? []; + const nodes: SidebarTreeNode[] = sources.map((source) => { + const sourceInfo = { + type: source.type, + id: source.id, + name: source.name, + count: source.count, + isRefreshing: source.isRefreshing ?? false, + }; + const sourceArg = { source: sourceInfo }; + let description: string; + if (source.isRefreshing) { + description = 'Refreshing...'; + } else if (source.type === 'galaxy' && source.galaxyFilter) { + description = `filter: "${source.galaxyFilter}" (${String(source.galaxyFilterResultCount ?? 0)} results)`; + } else { + description = `${source.count.toLocaleString()} collections`; + } + const sourceActions: SidebarNodeAction[] = + source.type === 'galaxy' + ? [ + { + id: `filter-${source.id}`, + label: 'Filter Collections', + icon: 'filter', + command: 'ansibleCollectionSources.filterGalaxyCollections', + }, + ...(source.galaxyFilter + ? [ + { + id: `clear-filter-${source.id}`, + label: 'Clear Filter', + icon: 'close', + command: 'ansibleCollectionSources.clearGalaxyFilter', + }, + ] + : []), + { + id: `refresh-${source.id}`, + label: 'Refresh Source', + icon: 'refresh', + command: 'ansibleCollectionSources.refreshSource', + args: [sourceArg], + }, + ...(input.enableAiFeatures + ? [ + { + id: `ai-src-${source.id}`, + label: 'Generate AI Summary', + icon: 'sparkle', + command: 'ansibleCollectionSources.aiSourceSummary', + args: [sourceArg], + }, + ] + : []), + ] + : [ + { + id: `install-src-${source.id}`, + label: 'Install from Source', + icon: 'cloud-download', + command: 'ansibleCollectionSources.installFromSource', + args: [sourceArg], + }, + { + id: `search-src-${source.id}`, + label: 'Search in Source', + icon: 'search', + command: 'ansibleCollectionSources.searchSource', + args: [sourceArg], + }, + { + id: `refresh-${source.id}`, + label: 'Refresh Source', + icon: 'refresh', + command: 'ansibleCollectionSources.refreshSource', + args: [sourceArg], + }, + ...(input.enableAiFeatures + ? [ + { + id: `ai-src-${source.id}`, + label: 'Generate AI Summary', + icon: 'sparkle', + command: 'ansibleCollectionSources.aiSourceSummary', + args: [sourceArg], + }, + ] + : []), + ]; + const sourceTip: string[] = [ + source.name, + '', + `Type: ${source.type === 'galaxy' ? 'Ansible Galaxy' : 'GitHub Organization'}`, + '', + `Collections: ${source.count.toLocaleString()}`, + ]; + if (source.lastUpdated) { + sourceTip.push('', `Last Updated: ${source.lastUpdated}`); + } + return { + id: `source-${source.id}`, + label: source.name, + description, + tooltip: sourceTip.join('\n'), + icon: source.type === 'galaxy' ? 'globe' : 'github', + actions: sourceActions, + children: source.topCollections?.map((col) => { + const version = col.version ?? ''; + const collection = { + namespace: col.namespace, + name: col.collectionName, + version, + org: col.org ?? source.id, + repository: col.repository ?? '', + description: col.description ?? '', + htmlUrl: col.htmlUrl ?? '', + installUrl: col.installUrl ?? '', + }; + const lazyExpand = + source.type === 'galaxy' + ? { + kind: 'galaxyCollection' as const, + namespace: col.namespace, + name: col.collectionName, + version, + } + : { + kind: 'githubCollection' as const, + org: col.org ?? source.id, + namespace: col.namespace, + name: col.collectionName, + version, + repository: col.repository ?? '', + }; + const collectionActions: SidebarNodeAction[] = + source.type === 'galaxy' + ? [ + { + id: `install-col-${col.namespace}.${col.collectionName}`, + label: 'Install Collection', + icon: 'cloud-download', + command: 'ansibleCollectionSources.installGalaxyCollection', + args: [{ collection }], + }, + ] + : [ + { + id: `refresh-docs-${col.namespace}.${col.collectionName}`, + label: 'Refresh Plugin Docs', + icon: 'refresh', + command: 'ansibleCollectionSources.refreshGitHubCollection', + args: [{ collection }], + }, + ]; + const colTip: string[] = [col.name]; + if (version) { + colTip.push('', `Version: ${version}`); + } + if (source.type === 'galaxy') { + if (col.downloadCount !== undefined) { + colTip.push('', `Downloads: ${col.downloadCount.toLocaleString()}`); + } + if (col.deprecated) { + colTip.push('', 'deprecated'); + } + } else { + if (col.org) { + colTip.push('', `Org: ${col.org}`); + } + if (col.description) { + colTip.push('', col.description); + } + } + return { + id: `source-col-${source.id}-${col.namespace}.${col.collectionName}`, + label: col.name, + description: version ? `v${version}` : undefined, + tooltip: colTip.join('\n'), + icon: 'library', + lazyChildren: Boolean(version), + expand: version ? lazyExpand : undefined, + actions: collectionActions, + }; + }), + }; + }); + return { + id: 'collectionSources', + title: 'Collection Sources', + headerActions: [ + { + id: 'sources-search', + label: 'Search Collections', + icon: 'search', + command: 'ansibleCollectionSources.search', + }, + { + id: 'sources-install', + label: 'Install Collection', + icon: 'cloud-download', + command: 'ansibleCollectionSources.install', + }, + { + id: 'sources-add', + label: 'Add GitHub Organization', + icon: 'add', + command: 'ansibleCollectionSources.addSource', + }, + { + id: 'sources-refresh', + label: 'Refresh All Sources', + icon: 'refresh', + command: 'ansibleCollectionSources.refresh', + }, + ...(input.enableAiFeatures + ? [ + { + id: 'sources-ai', + label: 'AI Summary', + icon: 'sparkle', + command: 'ansibleCollectionSources.aiSummary', + }, + ] + : []), + ], + nodes, + }; +} diff --git a/packages/services/src/sidebar/sections/collections.ts b/packages/services/src/sidebar/sections/collections.ts new file mode 100644 index 0000000000..d14b4e3057 --- /dev/null +++ b/packages/services/src/sidebar/sections/collections.ts @@ -0,0 +1,159 @@ +/** + * Installed Collections NavTree section. + */ +import type { SidebarNodeAction, SidebarSection, SidebarTreeNode } from '@ansible/common'; +import type { SidebarModelInput } from '../types'; + +/** + * Build Installed Collections: collection → plugin-type folders → plugins. + * @param input - Model inputs + * @returns Section snapshot + */ +export function buildCollections(input: SidebarModelInput): SidebarSection { + const headerActions: SidebarNodeAction[] = [ + { + id: 'col-search', + label: 'Search Plugins', + icon: 'search', + command: 'ansibleDevToolsCollections.search', + }, + { + id: 'col-refresh', + label: 'Refresh', + icon: 'refresh', + command: 'ansibleDevToolsCollections.refresh', + }, + ...(input.enableAiFeatures + ? [ + { + id: 'col-ai', + label: 'AI Summary', + icon: 'sparkle', + command: 'ansibleDevToolsCollections.aiSummary', + }, + ] + : []), + ]; + if (input.collectionsIndexing) { + return { + id: 'collections', + title: 'Installed Collections', + headerActions, + nodes: [ + { + id: 'collections-indexing', + label: 'Indexing collections...', + icon: 'loading', + tooltip: 'Scanning installed collections and plugins. This may take a moment.', + }, + ], + }; + } + const sorted = [...input.collections].sort((a, b) => a.info.name.localeCompare(b.info.name)); + const nodes: SidebarTreeNode[] = sorted.map((col) => { + const name = col.info.name; + const typeEntries = [...col.pluginTypes.entries()].sort((a, b) => a[0].localeCompare(b[0])); + const typeNodes: SidebarTreeNode[] = typeEntries.map(([pluginType, plugins]) => ({ + id: `type-${name}-${pluginType}`, + label: pluginType, + description: `(${String(plugins.length)})`, + icon: 'symbol-folder', + children: plugins.map((plugin) => { + const fqcn = plugin.fullName || `${name}.${plugin.name}`; + const pluginArg = { fullName: fqcn, pluginType }; + const tipParts = [fqcn]; + if (plugin.shortDescription) { + tipParts.push('', plugin.shortDescription); + } + tipParts.push('', 'Click to view documentation'); + return { + id: `plugin-${fqcn}`, + label: plugin.name, + description: plugin.shortDescription || undefined, + tooltip: tipParts.join('\n'), + icon: 'symbol-method', + actions: [ + { + id: `docs-${fqcn}`, + label: 'View Documentation', + icon: 'book', + command: 'ansibleDevToolsCollections.showPluginDoc', + args: [pluginArg], + }, + ...(input.enableAiFeatures + ? [ + { + id: `ai-${fqcn}`, + label: 'Generate AI Summary', + icon: 'sparkle', + command: 'ansibleDevToolsCollections.aiPluginSummary', + args: [pluginArg], + }, + ] + : []), + ], + }; + }), + })); + const colTip: string[] = [name]; + if (col.info.version) { + colTip.push('', `Version: ${col.info.version}`); + } + if (col.info.authors.length > 0) { + colTip.push('', `Authors: ${col.info.authors.join(', ')}`); + } + if (col.info.description) { + colTip.push('', col.info.description); + } + if (col.info.path) { + colTip.push('', '---', '', `Path: ${col.info.path}`); + } + return { + id: `col-${name}`, + label: name, + description: col.info.version ? `v${col.info.version}` : undefined, + tooltip: colTip.join('\n'), + icon: 'library', + actions: input.enableAiFeatures + ? [ + { + id: `ai-col-${name}`, + label: 'Generate AI Summary', + icon: 'sparkle', + command: 'ansibleDevToolsCollections.aiCollectionSummary', + args: [{ name }], + }, + ] + : undefined, + children: typeNodes.length > 0 ? typeNodes : undefined, + }; + }); + if (nodes.length === 0) { + return { + id: 'collections', + title: 'Installed Collections', + headerActions, + welcome: + 'No installed collections found.\nInstall ansible-dev-tools or refresh after selecting a Python environment.', + welcomeActions: [ + { + id: 'col-refresh-welcome', + label: 'Refresh', + command: 'ansibleDevToolsCollections.refresh', + }, + { + id: 'col-install-adt', + label: 'Install ansible-dev-tools', + command: 'ansibleDevToolsPackages.install', + }, + ], + nodes: [], + }; + } + return { + id: 'collections', + title: 'Installed Collections', + headerActions, + nodes, + }; +} diff --git a/packages/services/src/sidebar/sections/creator.ts b/packages/services/src/sidebar/sections/creator.ts new file mode 100644 index 0000000000..d9785b3d2b --- /dev/null +++ b/packages/services/src/sidebar/sections/creator.ts @@ -0,0 +1,209 @@ +/** + * Creator NavTree section. + */ +import { + formatLabel, + type CreatorStatus, + type SchemaNode, + type SidebarNodeAction, + type SidebarSection, + type SidebarTreeNode, +} from '@ansible/common'; +import type { SidebarModelInput } from '../types'; + +/** + * Build Creator (Init / Add schema tree). + * @param input - Model inputs + * @returns Section snapshot + */ +export function buildCreator(input: SidebarModelInput): SidebarSection { + const creator = input.creator ?? { status: 'unknown' as CreatorStatus, schema: null }; + const headerActions: SidebarNodeAction[] = [ + ...(input.enableAiFeatures + ? [ + { + id: 'creator-ai', + label: 'AI Summary', + icon: 'sparkle', + command: 'ansibleCreator.aiSummary', + }, + ] + : []), + { + id: 'creator-refresh', + label: 'Refresh', + icon: 'refresh', + command: 'ansibleCreator.refresh', + }, + ]; + if (!creator.schema) { + // Native CreatorController MessageNodes (clickable rows, not viewsWelcome) + if (creator.status === 'outdated') { + return { + id: 'creator', + title: 'Creator', + headerActions, + severity: 'warning', + nodes: [ + { + id: 'creator-outdated', + label: 'ansible-creator outdated', + description: creator.installedVersion + ? `v${creator.installedVersion} — upgrade required` + : 'Upgrade required', + tooltip: + 'The installed ansible-creator does not support the "schema" subcommand.\nUpgrade ansible-dev-tools to get the latest version.', + icon: 'warning', + warning: true, + actions: [ + { + id: 'creator-upgrade', + label: 'Upgrade ansible-dev-tools', + icon: 'arrow-up', + command: 'ansibleDevToolsPackages.upgrade', + }, + ], + }, + ], + }; + } + if (creator.status === 'not-installed') { + return { + id: 'creator', + title: 'Creator', + headerActions, + severity: 'warning', + nodes: [ + { + id: 'creator-missing', + label: 'ansible-creator not found', + description: 'Click to install', + tooltip: 'Install ansible-dev-tools to enable Creator features', + icon: 'warning', + warning: true, + actions: [ + { + id: 'creator-install', + label: 'Install ansible-dev-tools', + icon: 'cloud-download', + command: 'ansibleDevToolsPackages.install', + }, + ], + }, + ], + }; + } + return { + id: 'creator', + title: 'Creator', + headerActions, + nodes: [ + { + id: 'creator-loading', + label: 'Loading...', + icon: 'loading', + }, + ], + }; + } + const nodes: SidebarTreeNode[] = []; + if (creator.schema.subcommands?.init) { + nodes.push( + buildCreatorCategory( + 'Init', + creator.schema.subcommands.init, + ['init'], + input.enableAiFeatures, + ), + ); + } + if (creator.schema.subcommands?.add) { + nodes.push( + buildCreatorCategory( + 'Add', + creator.schema.subcommands.add, + ['add'], + input.enableAiFeatures, + ), + ); + } + return { + id: 'creator', + title: 'Creator', + headerActions, + nodes, + }; +} + +/** + * Build a creator category / command subtree from schema. + * @param label - Display label + * @param schema - Schema node + * @param path - Command path for openForm + * @param enableAiFeatures - When true, add Generate with AI on leaves + * @returns Tree node + */ +export function buildCreatorCategory( + label: string, + schema: SchemaNode, + path: string[], + enableAiFeatures: boolean, +): SidebarTreeNode { + const children: SidebarTreeNode[] = []; + if (schema.subcommands) { + for (const [name, subSchema] of Object.entries(schema.subcommands)) { + const subPath = [...path, name]; + if (subSchema.subcommands && Object.keys(subSchema.subcommands).length > 0) { + children.push( + buildCreatorCategory(formatLabel(name), subSchema, subPath, enableAiFeatures), + ); + } else { + const desc = subSchema.description ?? ''; + const cmdLabel = formatLabel(name); + children.push({ + id: `creator-cmd-${subPath.join('-')}`, + // Match CreatorController CommandNode: title = key, summary = description + label: cmdLabel, + description: + desc.length > 50 ? `${desc.substring(0, 47)}...` : desc || undefined, + tooltip: desc || cmdLabel, + icon: 'new-file', + actions: [ + { + id: `creator-open-${subPath.join('-')}`, + label: 'Open Creator Form', + icon: 'window', + command: 'ansibleCreator.openForm', + // Native: arguments: [commandPath, schema] + args: [subPath, subSchema], + }, + ...(enableAiFeatures + ? [ + { + id: `creator-ai-${subPath.join('-')}`, + label: 'Generate with AI', + icon: 'sparkle', + command: 'ansibleCreator.aiEntrySummary', + args: [ + { + label: cmdLabel, + schema: subSchema, + commandPath: subPath, + }, + ], + }, + ] + : []), + ], + }); + } + } + } + return { + id: `creator-cat-${path.join('-')}`, + label, + tooltip: schema.description ?? undefined, + icon: 'folder', + children: children.length > 0 ? children : undefined, + }; +} diff --git a/packages/services/src/sidebar/sections/devTools.ts b/packages/services/src/sidebar/sections/devTools.ts new file mode 100644 index 0000000000..c68f324a13 --- /dev/null +++ b/packages/services/src/sidebar/sections/devTools.ts @@ -0,0 +1,89 @@ +/** + * Ansible Dev Tools NavTree section. + */ +import type { SidebarNodeAction, SidebarSection } from '@ansible/common'; +import type { SidebarModelInput } from '../types'; + +const DEVTOOLS_HEADER_ACTIONS: SidebarNodeAction[] = [ + { + id: 'adt-install', + label: 'Install ansible-dev-tools', + icon: 'cloud-download', + command: 'ansibleDevToolsPackages.install', + }, + { + id: 'adt-upgrade', + label: 'Upgrade ansible-dev-tools', + icon: 'arrow-up', + command: 'ansibleDevToolsPackages.upgrade', + }, + { + id: 'adt-refresh', + label: 'Refresh', + icon: 'refresh', + command: 'ansibleDevToolsPackages.refresh', + }, +]; +/** + * Build the Ansible Dev Tools section. + * @param input - Model inputs + * @returns Section snapshot + */ +export function buildDevTools(input: SidebarModelInput): SidebarSection { + if (!input.pythonAvailable) { + return { + id: 'devTools', + title: 'Ansible Dev Tools', + severity: 'warning', + headerActions: DEVTOOLS_HEADER_ACTIONS, + welcome: 'No Python extension detected.', + welcomeActions: [ + { + id: 'install-python', + label: 'Install Python Extension', + command: 'workbench.extensions.search', + args: ['ms-python.python'], + }, + ], + nodes: [], + }; + } + if (!input.hasDevTools) { + return { + id: 'devTools', + title: 'Ansible Dev Tools', + severity: 'error', + headerActions: DEVTOOLS_HEADER_ACTIONS, + welcome: 'No ansible-dev-tools packages found.', + welcomeActions: [ + { + id: 'install-adt', + label: 'Install ansible-dev-tools', + command: 'ansibleDevToolsPackages.install', + }, + ], + nodes: [], + }; + } + return { + id: 'devTools', + title: 'Ansible Dev Tools', + headerActions: DEVTOOLS_HEADER_ACTIONS, + nodes: input.devTools.map((pkg) => ({ + id: `pkg-${pkg.name}`, + label: pkg.name, + description: pkg.version, + icon: 'package', + tooltip: pkg.location, + })), + }; +} + +/** + * Whether Dev Tools should auto-open (Python ok but ADT missing). + * @param input - Snapshot inputs + * @returns True when the section should be suggested open + */ +export function shouldSuggestDevTools(input: SidebarModelInput): boolean { + return input.pythonAvailable && !input.hasDevTools; +} diff --git a/packages/services/src/sidebar/sections/envManagers.ts b/packages/services/src/sidebar/sections/envManagers.ts new file mode 100644 index 0000000000..cdd44d6077 --- /dev/null +++ b/packages/services/src/sidebar/sections/envManagers.ts @@ -0,0 +1,151 @@ +/** + * Environment Managers NavTree section. + */ +import type { SidebarNodeAction, SidebarSection, SidebarTreeNode } from '@ansible/common'; +import type { SidebarModelInput } from '../types'; + +const ENV_HEADER_ACTIONS: SidebarNodeAction[] = [ + { + id: 'env-create', + label: 'Create Environment', + icon: 'add', + command: 'ansibleDevToolsEnvManagers.create', + }, + { + id: 'env-refresh', + label: 'Refresh', + icon: 'refresh', + command: 'ansibleDevToolsEnvManagers.refresh', + }, +]; +/** + * Build the Environment Managers section. + * @param input - Model inputs + * @returns Section snapshot + */ +export function buildEnvManagers(input: SidebarModelInput): SidebarSection { + if (!input.pythonAvailable) { + return { + id: 'envManagers', + title: 'Environment Managers', + severity: 'warning', + headerActions: ENV_HEADER_ACTIONS, + welcome: 'No Python extension detected.', + welcomeActions: [ + { + id: 'install-python', + label: 'Install Python Extension', + command: 'workbench.extensions.search', + args: ['ms-python.python'], + }, + ], + nodes: [], + }; + } + // Native viewsWelcome when ansible.pythonEnvCapability == python-only + if (input.pythonEnvCapability === 'python-only' && input.envManagers.length === 0) { + return { + id: 'envManagers', + title: 'Environment Managers', + severity: 'info', + headerActions: ENV_HEADER_ACTIONS, + welcome: + 'Environment management uses terminal fallbacks.\nFor the best experience, install the Python Environments extension.', + welcomeActions: [ + { + id: 'install-python-envs', + label: 'Install Python Environments', + command: 'workbench.extensions.search', + args: ['ms-python.vscode-python-envs'], + }, + { + id: 'create-venv', + label: 'Create Virtual Environment', + command: 'ansibleDevToolsEnvManagers.create', + }, + ], + nodes: [], + }; + } + // Native: manager "not recommended" only when a global env is selected + const globalEnvSelected = input.envManagers.some( + (m) => m.isGlobal && m.environments.some((e) => e.selected), + ); + const nodes: SidebarTreeNode[] = input.envManagers.map((manager) => ({ + id: manager.id, + label: manager.name, + icon: manager.isGlobal ? 'globe' : 'folder', + warning: manager.isGlobal && globalEnvSelected, + description: manager.isGlobal && globalEnvSelected ? 'not recommended' : undefined, + tooltip: + manager.isGlobal && globalEnvSelected + ? 'Global Python Environment Selected\n\n' + + 'Use of global Python environments for Ansible development is strongly discouraged.\n\n' + + 'Please create and select a virtual environment instead.' + : undefined, + children: manager.environments.map((env) => { + const lines: string[] = []; + if (manager.isGlobal) { + lines.push('Not recommended for Ansible development', ''); + } + lines.push(env.label); + if (env.version) { + lines.push('', `Version: ${env.version}`); + } + if (env.path) { + lines.push('', `Path: ${env.path}`); + } + return { + id: env.id, + label: env.label, + description: env.version, + tooltip: lines.join('\n'), + icon: env.selected ? 'check' : 'symbol-misc', + selected: env.selected, + warning: env.warning ?? manager.isGlobal, + actions: [ + { + id: `select-${env.id}`, + label: 'Select Environment', + icon: 'check', + command: 'ansibleDevTools.selectEnvironment', + // Host resolves envId → full PythonEnvironment + args: [{ envId: env.id }], + }, + ], + }; + }), + })); + if (input.pythonEnvCapability === 'python-only') { + nodes.unshift({ + id: 'env-python-only-hint', + label: 'Using terminal fallbacks — install Python Environments for the best experience', + icon: 'info', + actions: [ + { + id: 'install-python-envs-inline', + label: 'Install Python Environments', + icon: 'extensions', + command: 'workbench.extensions.search', + args: ['ms-python.vscode-python-envs'], + }, + ], + }); + } + return { + id: 'envManagers', + title: 'Environment Managers', + headerActions: ENV_HEADER_ACTIONS, + severity: input.pythonEnvCapability === 'python-only' ? 'info' : undefined, + nodes, + }; +} + +/** + * Whether Environment Managers should auto-open (Python unavailable). + * @param input - Snapshot inputs + * @returns True when the section should be suggested open + */ +export function shouldSuggestEnvManagers(input: SidebarModelInput): boolean { + return !input.pythonAvailable; +} diff --git a/packages/services/src/sidebar/sections/executionEnvironments.ts b/packages/services/src/sidebar/sections/executionEnvironments.ts new file mode 100644 index 0000000000..bd9f911064 --- /dev/null +++ b/packages/services/src/sidebar/sections/executionEnvironments.ts @@ -0,0 +1,158 @@ +/** + * Execution Environments NavTree section. + */ +import { + shortExecutionEnvironmentName, + type SidebarNodeAction, + type SidebarSection, + type SidebarTreeNode, +} from '@ansible/common'; +import type { SidebarModelInput } from '../types'; + +/** + * Build Execution Environments (image list). + * @param input - Model inputs + * @returns Section snapshot + */ +export function buildExecutionEnvironments(input: SidebarModelInput): SidebarSection { + const headerActions: SidebarNodeAction[] = [ + { + id: 'ee-build', + label: 'Build from Definition', + icon: 'package', + command: 'ansibleExecutionEnvironments.buildFromDefinition', + }, + ...(input.enableAiFeatures + ? [ + { + id: 'ee-ai', + label: 'AI Summary', + icon: 'sparkle', + command: 'ansibleExecutionEnvironments.aiSummary', + }, + ] + : []), + { + id: 'ee-refresh', + label: 'Refresh', + icon: 'refresh', + command: 'ansibleExecutionEnvironments.refresh', + }, + ]; + if (input.executionEnvironmentsLoading) { + return { + id: 'executionEnvironments', + title: 'Execution Environments', + headerActions, + nodes: [ + { + id: 'ee-loading', + label: 'Loading...', + icon: 'loading', + tooltip: 'Loading execution environments', + }, + ], + }; + } + const eeError = input.executionEnvironmentsError; + if (eeError) { + const navigatorMissing = + eeError.includes('not found in PATH') || eeError.includes('not found'); + if (navigatorMissing) { + return { + id: 'executionEnvironments', + title: 'Execution Environments', + headerActions, + severity: 'warning', + nodes: [ + { + id: 'ee-navigator-missing', + label: 'ansible-navigator not found', + description: 'Click to install', + tooltip: 'Install ansible-dev-tools to enable Execution Environments', + icon: 'warning', + warning: true, + actions: [ + { + id: 'ee-install-adt', + label: 'Install ansible-dev-tools', + icon: 'cloud-download', + command: 'ansibleDevToolsPackages.install', + }, + ], + }, + ], + }; + } + return { + id: 'executionEnvironments', + title: 'Execution Environments', + headerActions, + severity: 'error', + nodes: [ + { + id: 'ee-error', + label: 'Error loading execution environments', + description: eeError, + tooltip: eeError, + icon: 'error', + warning: true, + }, + ], + }; + } + const ees = input.executionEnvironments ?? []; + const nodes: SidebarTreeNode[] = ees.map((ee) => { + const shortName = shortExecutionEnvironmentName(ee.full_name); + return { + id: `ee-${ee.full_name}`, + label: shortName, + // Created / full path live in tooltip — keep the row scannable + tooltip: [ee.full_name, '', `Image ID: ${ee.image_id}`, `Created: ${ee.created}`].join( + '\n', + ), + icon: 'package', + // Native: Collapsed → Info / Collections / Python / System via loadDetails + lazyChildren: true, + expand: { kind: 'eeDetail', fullName: ee.full_name }, + actions: [ + { + id: `ee-detail-${ee.full_name}`, + label: 'Show Details', + icon: 'info', + command: 'ansibleExecutionEnvironments.showDetail', + // Native handler: (eeName: string) + args: [ee.full_name], + }, + ...(input.enableAiFeatures + ? [ + { + id: `ee-ai-${ee.full_name}`, + label: 'Generate AI Summary', + icon: 'sparkle', + command: 'ansibleExecutionEnvironments.aiEESummary', + args: [{ label: ee.full_name }], + }, + ] + : []), + ], + }; + }); + return { + id: 'executionEnvironments', + title: 'Execution Environments', + headerActions, + nodes: + nodes.length > 0 + ? nodes + : [ + { + id: 'ee-empty', + label: 'No execution environments found', + description: 'Build or pull an EE image', + tooltip: 'Build or pull an execution environment image', + icon: 'info', + }, + ], + }; +} diff --git a/packages/services/src/sidebar/sections/lightspeed.ts b/packages/services/src/sidebar/sections/lightspeed.ts new file mode 100644 index 0000000000..3ebc4e9ee4 --- /dev/null +++ b/packages/services/src/sidebar/sections/lightspeed.ts @@ -0,0 +1,63 @@ +/** + * Lightspeed NavTree section. + */ +import type { SidebarSection } from '@ansible/common'; +import type { SidebarModelInput } from '../types'; + +/** + * Build Lightspeed section when the Lightspeed package is enabled. + * @param input - Model inputs + * @returns Section snapshot + */ +export function buildLightspeed(input: SidebarModelInput): SidebarSection { + // Auth-gated like LightspeedViewProvider: sign-in only vs generation tools + const items = + input.lightspeedItems ?? + (input.lightspeedAuthenticated + ? [ + { + label: 'Generate Playbook', + icon: 'wand', + command: 'ansible.lightspeed.playbookGeneration', + }, + { + label: 'Generate Role', + icon: 'wand', + command: 'ansible.lightspeed.roleGeneration', + }, + { + label: 'Explain Playbook', + icon: 'book', + command: 'ansible.lightspeed.playbookExplanation', + }, + { + label: 'Explain Role', + icon: 'book', + command: 'ansible.lightspeed.roleExplanation', + }, + ] + : [ + { + label: 'Sign in to Ansible Lightspeed', + icon: 'sign-in', + command: 'ansible.lightspeed.oauth', + }, + ]); + return { + id: 'lightspeed', + title: 'Lightspeed', + nodes: items.map((item) => ({ + id: `ls-${item.command}`, + label: item.label, + icon: item.icon, + actions: [ + { + id: `ls-run-${item.command}`, + label: item.label, + icon: item.icon, + command: item.command, + }, + ], + })), + }; +} diff --git a/packages/services/src/sidebar/sections/playbooks.ts b/packages/services/src/sidebar/sections/playbooks.ts new file mode 100644 index 0000000000..95dd1ac48d --- /dev/null +++ b/packages/services/src/sidebar/sections/playbooks.ts @@ -0,0 +1,211 @@ +/** + * Playbooks NavTree section. + */ +import * as path from 'path'; +import type { SidebarNodeAction, SidebarSection, SidebarTreeNode } from '@ansible/common'; +import type { DiscoveredPlaybook } from '../../PlaybookDiscovery'; +import type { SidebarModelInput } from '../types'; + +/** + * Build the Playbooks section (folder hierarchy + multi-root like PlaybooksController). + * @param input - Model inputs + * @returns Section snapshot + */ +export function buildPlaybooks(input: SidebarModelInput): SidebarSection { + const headerActions: SidebarNodeAction[] = [ + { + id: 'pb-defaults', + label: 'Edit Default Configuration', + icon: 'settings-gear', + command: 'ansiblePlaybooks.editDefaults', + }, + { + id: 'pb-refresh', + label: 'Refresh', + icon: 'refresh', + command: 'ansiblePlaybooks.refresh', + }, + ]; + // Legacy single-root callers may still set `playbooks` on the input bag. + const legacyPlaybooks = (input as { playbooks?: DiscoveredPlaybook[] }).playbooks ?? []; + const workspaces = + input.playbookWorkspaces ?? + (legacyPlaybooks.length > 0 ? [{ name: '', path: '', playbooks: legacyPlaybooks }] : []); + const total = workspaces.reduce((n, ws) => n + ws.playbooks.length, 0); + if (total === 0) { + return { + id: 'playbooks', + title: 'Playbooks', + headerActions, + welcome: 'No playbooks found in the workspace.', + nodes: [], + }; + } + let nodes: SidebarTreeNode[]; + if (workspaces.length === 1) { + const ws = workspaces[0]; + nodes = buildPlaybookFolderContents( + ws.playbooks, + ws.path || path.dirname(ws.playbooks[0]?.path ?? ''), + input.enableAiFeatures, + ); + } else { + nodes = workspaces.map((ws) => ({ + id: `ws-${ws.path}`, + label: ws.name || path.basename(ws.path), + tooltip: ws.path, + icon: 'root-folder', + children: buildPlaybookFolderContents(ws.playbooks, ws.path, input.enableAiFeatures), + })); + } + return { + id: 'playbooks', + title: 'Playbooks', + headerActions, + nodes, + }; +} + +/** + * Folder + playbook nodes for one directory (mirrors PlaybooksController._buildFolderContents). + * @param playbooks - Playbooks under the workspace + * @param folderPath - Absolute folder path being rendered + * @param enableAiFeatures - AI summary on playbook rows + * @returns Sorted subfolder and playbook nodes + */ +export function buildPlaybookFolderContents( + playbooks: DiscoveredPlaybook[], + folderPath: string, + enableAiFeatures: boolean, +): SidebarTreeNode[] { + const subfolders = new Map(); + const direct: DiscoveredPlaybook[] = []; + for (const pb of playbooks) { + const relative = folderPath + ? path.relative(folderPath, pb.path) + : pb.relativePath || path.basename(pb.path); + const parts = relative.split(path.sep).filter(Boolean); + if (parts.length <= 1) { + direct.push(pb); + } else { + const sub = parts[0]; + const list = subfolders.get(sub) ?? []; + list.push(pb); + subfolders.set(sub, list); + } + } + const nodes: SidebarTreeNode[] = []; + for (const name of [...subfolders.keys()].sort()) { + const childPath = folderPath ? path.join(folderPath, name) : name; + nodes.push({ + id: `pbfolder-${childPath}`, + label: name, + tooltip: childPath, + icon: 'folder', + children: buildPlaybookFolderContents( + subfolders.get(name) ?? [], + childPath, + enableAiFeatures, + ), + }); + } + for (const pb of direct.sort((a, b) => + path.basename(a.path).localeCompare(path.basename(b.path)), + )) { + nodes.push(buildPlaybookNode(pb, enableAiFeatures)); + } + return nodes; +} + +/** + * Single playbook row with native inline actions and goToPlay on plays. + * @param pb - Discovered playbook + * @param enableAiFeatures - Include AI summary action + * @returns Tree node + */ +export function buildPlaybookNode( + pb: DiscoveredPlaybook, + enableAiFeatures: boolean, +): SidebarTreeNode { + const playCount = pb.plays.length; + const basename = path.basename(pb.path); + const playbookPlain = { + name: basename, + path: pb.path, + relativePath: pb.relativePath, + plays: pb.plays, + }; + const playbookArg = { playbook: playbookPlain }; + const playLines = pb.plays.map((p) => `- ${p.name || 'Play'} (hosts: ${p.hosts})`).join('\n'); + const pbTooltip = [basename, '', pb.path, ...(playLines ? ['', 'Plays:', playLines] : [])].join( + '\n', + ); + return { + id: `pb-${pb.path}`, + label: basename, + description: `${String(playCount)} play${playCount !== 1 ? 's' : ''}`, + tooltip: pbTooltip, + icon: 'notebook', + actions: [ + { + id: `run-${pb.path}`, + label: 'Run Playbook', + icon: 'play', + command: 'ansiblePlaybooks.run', + args: [playbookArg], + }, + { + id: `progress-${pb.path}`, + label: 'Run with Progress Viewer', + icon: 'graph', + command: 'ansiblePlaybooks.runWithProgress', + args: [playbookArg], + }, + { + id: `open-${pb.path}`, + label: 'Edit Playbook', + icon: 'edit', + command: 'ansiblePlaybooks.openPlaybook', + args: [playbookArg], + }, + { + id: `config-${pb.path}`, + label: 'Edit Configuration', + icon: 'gear', + command: 'ansiblePlaybooks.editConfig', + args: [playbookArg], + }, + ...(enableAiFeatures + ? [ + { + id: `ai-${pb.path}`, + label: 'Generate AI Summary', + icon: 'sparkle', + command: 'ansiblePlaybooks.aiSummary', + args: [playbookArg], + }, + ] + : []), + ], + children: pb.plays.map((play, index) => ({ + id: `play-${pb.path}-${String(index)}`, + label: play.name || `Play ${String(index + 1)}`, + description: `hosts: ${play.hosts}`, + tooltip: [ + `Play: ${play.name || `Play ${String(index + 1)}`}`, + `Hosts: ${play.hosts}`, + `Line: ${String(play.lineNumber)}`, + ].join('\n'), + icon: 'target', + actions: [ + { + id: `goto-${pb.path}-${String(index)}`, + label: 'Go to Play', + icon: 'go-to-file', + command: 'ansiblePlaybooks.goToPlay', + args: [playbookPlain, play], + }, + ], + })), + }; +} diff --git a/packages/services/src/sidebar/types.ts b/packages/services/src/sidebar/types.ts new file mode 100644 index 0000000000..2db815e08c --- /dev/null +++ b/packages/services/src/sidebar/types.ts @@ -0,0 +1,118 @@ +/** + * Sidebar NavTree model input types (ADR-025). + */ +import type { + CollectionData, + CreatorStatus, + ExecutionEnvironment, + SchemaNode, + SidebarEnvManagerInput, + SkillEntry, + SkillSource, +} from '@ansible/common'; +import type { DevToolPackage } from '../DevToolsService'; +import type { DiscoveredPlaybook } from '../PlaybookDiscovery'; + +export interface SidebarAiToolInput { + category: string; + categoryLabel: string; + name: string; + label: string; + examplePrompt: string; + /** Full tool payload for ansibleMcpTools.useInChat */ + toolInfo: unknown; +} + +/** Lightspeed entry when the Lightspeed package is active. */ +export interface SidebarLightspeedItem { + label: string; + icon: string; + command: string; +} + +/** Root collection-source row (Galaxy / GitHub org). */ +export interface SidebarCollectionSourceInput { + id: string; + name: string; + type: 'galaxy' | 'github'; + count: number; + isRefreshing?: boolean; + /** Active Galaxy name filter (native collectionSourceGalaxyFiltered). */ + galaxyFilter?: string; + galaxyFilterResultCount?: number; + /** Collections shown under this source (Galaxy: top-10 or filter hits; GitHub: all). */ + /** Locale string for native source tooltip "Last Updated". */ + lastUpdated?: string; + topCollections?: { + /** Display label (usually namespace.name). */ + name: string; + version?: string; + namespace: string; + collectionName: string; + /** GitHub org when type is github. */ + org?: string; + repository?: string; + description?: string; + htmlUrl?: string; + installUrl?: string; + /** Galaxy download count for tooltip. */ + downloadCount?: number; + deprecated?: boolean; + }[]; +} + +/** Creator schema + readiness for the Creator section. */ +export interface SidebarCreatorInput { + status: CreatorStatus; + installedVersion?: string; + schema: SchemaNode | null; +} + +/** Playbooks discovered under one workspace folder (multi-root aware). */ +export interface SidebarPlaybookWorkspaceInput { + name: string; + path: string; + playbooks: DiscoveredPlaybook[]; +} + +export interface SidebarModelInput { + pythonAvailable: boolean; + /** + * Capability tier from PythonEnvironmentService (ADR-019). + * Drives python-only welcome when ms-python.vscode-python-envs is missing. + */ + pythonEnvCapability?: 'full' | 'envs-no-pet' | 'python-only' | 'unavailable'; + enableAiFeatures: boolean; + envManagers: SidebarEnvManagerInput[]; + devTools: DevToolPackage[]; + hasDevTools: boolean; + collections: CollectionData[]; + /** True while CollectionsService is indexing (native "Indexing collections..."). */ + collectionsIndexing?: boolean; + collectionSources?: SidebarCollectionSourceInput[]; + executionEnvironments?: ExecutionEnvironment[]; + /** True while ExecutionEnvService is loading the image list. */ + executionEnvironmentsLoading?: boolean; + /** Error message from EE load (e.g. ansible-navigator not in PATH). */ + executionEnvironmentsError?: string; + creator?: SidebarCreatorInput; + /** @deprecated Prefer playbookWorkspaces for multi-root parity. */ + playbooks: DiscoveredPlaybook[]; + playbookWorkspaces?: SidebarPlaybookWorkspaceInput[]; + aiTools?: SidebarAiToolInput[]; + /** When false, show MCP-not-configured warning (native McpWarningNode). */ + mcpConfigured?: boolean; + /** IDE label for MCP warning copy (e.g. "Cursor"). */ + mcpIdeLabel?: string; + skillSources?: SkillSource[]; + skills?: SkillEntry[]; + lightspeedEnabled?: boolean; + lightspeedAuthenticated?: boolean; + lightspeedItems?: SidebarLightspeedItem[]; +} + +/** Options for an immediate header-only skeleton snapshot. */ +export interface SidebarSkeletonOptions { + enableAiFeatures?: boolean; + lightspeedEnabled?: boolean; +} diff --git a/packages/services/test/SidebarModel.test.ts b/packages/services/test/SidebarModel.test.ts new file mode 100644 index 0000000000..9c5506ad60 --- /dev/null +++ b/packages/services/test/SidebarModel.test.ts @@ -0,0 +1,721 @@ +import { describe, expect, it } from 'vitest'; +import { SidebarModel } from '../src/SidebarModel'; +import { assembleAiTools } from '../src/sidebar/assembleSidebarInput'; + +describe('SidebarModel', () => { + const model = new SidebarModel(); + + it('suggests envManagers when Python is unavailable', () => { + const input = { + pythonAvailable: false, + enableAiFeatures: false, + envManagers: [], + devTools: [], + hasDevTools: false, + collections: [], + playbooks: [], + }; + expect(model.suggestOpenSection(input)).toBe('envManagers'); + const snap = model.buildSnapshot(input); + expect(snap.suggestedOpenSectionId).toBe('envManagers'); + const welcome = snap.sections.find((s) => s.id === 'envManagers')?.welcomeActions?.[0]; + expect(welcome?.label).toBe('Install Python Extension'); + expect(welcome?.args).toEqual(['ms-python.python']); + }); + + it('suggests devTools when ADT packages are missing', () => { + const input = { + pythonAvailable: true, + enableAiFeatures: true, + envManagers: [ + { + id: 'venv', + name: 'venv', + isGlobal: false, + environments: [ + { + id: 'e1', + label: 'ansible-dev', + version: '3.12', + selected: true, + }, + ], + }, + ], + devTools: [], + hasDevTools: false, + collections: [], + playbooks: [], + }; + expect(model.suggestOpenSection(input)).toBe('devTools'); + const snap = model.buildSnapshot(input); + expect(snap.sections.find((s) => s.id === 'devTools')?.welcomeActions?.[0]?.command).toBe( + 'ansibleDevToolsPackages.install', + ); + }); + + it('collapses all when healthy', () => { + const input = { + pythonAvailable: true, + enableAiFeatures: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + }; + expect(model.suggestOpenSection(input)).toBeNull(); + expect( + model.buildSnapshot(input).sections.find((s) => s.id === 'devTools')?.nodes, + ).toHaveLength(1); + }); + + it('includes view/title header actions on env managers', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + }); + const env = snap.sections.find((s) => s.id === 'envManagers'); + expect(env?.headerActions?.map((a) => a.command)).toEqual([ + 'ansibleDevToolsEnvManagers.create', + 'ansibleDevToolsEnvManagers.refresh', + ]); + }); + + it('buildSkeletonSnapshot paints headers with loading bodies', () => { + const snap = model.buildSkeletonSnapshot({ + enableAiFeatures: true, + lightspeedEnabled: true, + }); + expect(snap.sections.map((s) => s.id)).toEqual([ + 'envManagers', + 'devTools', + 'collections', + 'collectionSources', + 'executionEnvironments', + 'creator', + 'playbooks', + 'aiTools', + 'aiSkills', + 'lightspeed', + ]); + expect(snap.sections.every((s) => s.loading && s.nodes[0]?.label === 'Loading…')).toBe( + true, + ); + expect(snap.suggestedOpenSectionId).toBeNull(); + }); + + it('includes all always-on native sections in order', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + }); + expect(snap.sections.map((s) => s.id)).toEqual([ + 'envManagers', + 'devTools', + 'collections', + 'collectionSources', + 'executionEnvironments', + 'creator', + 'playbooks', + ]); + }); + + it('appends AI Tools and AI Skills when AI features are enabled', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: true, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + aiTools: [ + { + category: 'discovery', + categoryLabel: 'Discovery', + name: 'search_collections', + label: 'Search collections', + examplePrompt: 'search for collections', + toolInfo: { name: 'search_collections' }, + }, + ], + skillSources: [ + { + id: 'builtin', + type: 'builtin', + url: 'bundled', + trust: 'certified', + }, + ], + skills: [ + { + id: 'builtin/demo', + source: 'builtin', + module: 'demo', + name: 'Demo Skill', + description: 'A demo skill', + triggers: [], + category: 'domain', + trust: 'certified', + tags: [], + }, + ], + }); + expect(snap.sections.map((s) => s.id)).toEqual([ + 'envManagers', + 'devTools', + 'collections', + 'collectionSources', + 'executionEnvironments', + 'creator', + 'playbooks', + 'aiTools', + 'aiSkills', + ]); + const aiNodes = snap.sections.find((s) => s.id === 'aiTools')?.nodes ?? []; + const discovery = aiNodes.find((n) => n.label === 'Discovery'); + expect(discovery?.description).toBe('1 tool'); + }); + + it('appends Lightspeed when enabled', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: false, + lightspeedEnabled: true, + lightspeedItems: [ + { + label: 'Generate Playbook', + icon: 'wand', + command: 'ansible.lightspeed.playbookGeneration', + }, + ], + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + }); + expect(snap.sections.map((s) => s.id).at(-1)).toBe('lightspeed'); + }); + + it('collection source collections are lazy-expandable like native', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: true, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + collectionSources: [ + { + id: 'galaxy', + name: 'Ansible Galaxy', + type: 'galaxy', + count: 100, + galaxyFilter: 'aws', + galaxyFilterResultCount: 1, + topCollections: [ + { + name: 'amazon.aws', + namespace: 'amazon', + collectionName: 'aws', + version: '1.0.0', + }, + ], + }, + { + id: 'ansible', + name: 'ansible', + type: 'github', + count: 2, + topCollections: [ + { + name: 'ansible.posix', + namespace: 'ansible', + collectionName: 'posix', + version: '1.5.0', + org: 'ansible', + repository: 'ansible/ansible.posix', + }, + ], + }, + ], + }); + const section = snap.sections.find((s) => s.id === 'collectionSources'); + expect(section?.headerActions?.map((a) => a.command)).toEqual([ + 'ansibleCollectionSources.search', + 'ansibleCollectionSources.install', + 'ansibleCollectionSources.addSource', + 'ansibleCollectionSources.refresh', + 'ansibleCollectionSources.aiSummary', + ]); + const galaxy = section?.nodes[0]; + expect(galaxy?.description).toContain('filter: "aws"'); + expect(galaxy?.actions?.map((a) => a.command)).toContain( + 'ansibleCollectionSources.clearGalaxyFilter', + ); + const col = galaxy?.children?.[0]; + expect(col?.lazyChildren).toBe(true); + expect(col?.expand).toEqual({ + kind: 'galaxyCollection', + namespace: 'amazon', + name: 'aws', + version: '1.0.0', + }); + expect(col?.actions?.[0]?.command).toBe('ansibleCollectionSources.installGalaxyCollection'); + const gh = section?.nodes[1]; + expect(gh?.actions?.map((a) => a.command)).toContain( + 'ansibleCollectionSources.installFromSource', + ); + expect(gh?.children?.[0]?.actions?.[0]?.command).toBe( + 'ansibleCollectionSources.refreshGitHubCollection', + ); + }); + + it('creator leaf matches native title/summary and openForm args', () => { + const schema = { + name: 'ansible-creator', + subcommands: { + init: { + name: 'init', + subcommands: { + collection: { + name: 'collection', + description: 'Create a new Ansible collection project.', + }, + }, + }, + }, + }; + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: true, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + creator: { status: 'ready', schema }, + }); + const init = snap.sections.find((s) => s.id === 'creator')?.nodes[0]; + const collection = init?.children?.[0]; + expect(collection?.label).toBe('Collection'); + expect(collection?.description).toBe('Create a new Ansible collection project.'); + expect(collection?.actions?.[0]).toMatchObject({ + command: 'ansibleCreator.openForm', + args: [['init', 'collection'], schema.subcommands.init.subcommands.collection], + }); + expect(collection?.actions?.[1]).toMatchObject({ + command: 'ansibleCreator.aiEntrySummary', + label: 'Generate with AI', + }); + }); + + it('passes playbook payload args on edit/run actions (not node ids)', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: true, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [ + { + name: 'site.yml', + path: '/tmp/ws/site.yml', + relativePath: 'site.yml', + plays: [{ name: 'Play 1', hosts: 'all', lineNumber: 1 }], + }, + ], + playbookWorkspaces: [ + { + name: 'ws', + path: '/tmp/ws', + playbooks: [ + { + name: 'site.yml', + path: '/tmp/ws/site.yml', + relativePath: 'site.yml', + plays: [{ name: 'Play 1', hosts: 'all', lineNumber: 1 }], + }, + ], + }, + ], + }); + const pb = snap.sections.find((s) => s.id === 'playbooks')?.nodes[0]; + const edit = pb?.actions?.find((a) => a.command === 'ansiblePlaybooks.openPlaybook'); + expect(edit?.args?.[0]).toEqual({ + playbook: { + name: 'site.yml', + path: '/tmp/ws/site.yml', + relativePath: 'site.yml', + plays: [{ name: 'Play 1', hosts: 'all', lineNumber: 1 }], + }, + }); + expect(pb?.children?.[0]?.actions?.[0]?.command).toBe('ansiblePlaybooks.goToPlay'); + }); + + it('env leaves select via envId args for host enrich', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: false, + envManagers: [ + { + id: 'venv', + name: 'venv', + isGlobal: false, + environments: [ + { + id: 'env-1', + label: 'ansible-dev', + version: '3.12', + path: '/home/user/.venv', + selected: true, + }, + ], + }, + ], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + }); + const env = snap.sections.find((s) => s.id === 'envManagers')?.nodes[0]?.children?.[0]; + expect(env?.actions?.[0]).toMatchObject({ + command: 'ansibleDevTools.selectEnvironment', + args: [{ envId: 'env-1' }], + }); + expect(env?.tooltip).toContain('ansible-dev'); + expect(env?.tooltip).toContain('Version: 3.12'); + expect(env?.tooltip).toContain('Path: /home/user/.venv'); + }); + + it('shows MCP warning and Lightspeed auth-gated sign-in', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: true, + mcpConfigured: false, + mcpIdeLabel: 'Cursor', + lightspeedEnabled: true, + lightspeedAuthenticated: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + aiTools: [], + }); + const ai = snap.sections.find((s) => s.id === 'aiTools'); + expect(ai?.nodes[0]?.id).toBe('mcp-warning'); + expect(ai?.nodes[0]?.actions?.[0]?.command).toBe('ansibleMcpTools.configure'); + const ls = snap.sections.find((s) => s.id === 'lightspeed'); + expect(ls?.nodes).toHaveLength(1); + expect(ls?.nodes[0]?.command ?? ls?.nodes[0]?.actions?.[0]?.command).toBe( + 'ansible.lightspeed.oauth', + ); + }); + + it('EE rows are lazy-expandable for detail categories', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + executionEnvironments: [ + { + created: 'today', + execution_environment: true, + full_name: 'ghcr.io/ansible/community-ansible-dev-tools:devel', + image_id: 'sha', + }, + ], + }); + const ee = snap.sections.find((s) => s.id === 'executionEnvironments')?.nodes[0]; + expect(ee?.label).toBe('community-ansible-dev-tools:devel'); + expect(ee?.description).toBeUndefined(); + expect(ee?.tooltip).toContain('ghcr.io/ansible/community-ansible-dev-tools:devel'); + expect(ee?.tooltip).toContain('Created: today'); + expect(ee?.lazyChildren).toBe(true); + expect(ee?.expand).toEqual({ + kind: 'eeDetail', + fullName: 'ghcr.io/ansible/community-ansible-dev-tools:devel', + }); + expect(ee?.actions?.[0]?.args).toEqual([ + 'ghcr.io/ansible/community-ansible-dev-tools:devel', + ]); + }); + + it('shows python-only welcome with python-envs install when no managers', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + pythonEnvCapability: 'python-only', + enableAiFeatures: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + }); + const env = snap.sections.find((s) => s.id === 'envManagers'); + expect(env?.welcomeActions?.map((a) => a.command)).toEqual([ + 'workbench.extensions.search', + 'ansibleDevToolsEnvManagers.create', + ]); + expect(env?.welcomeActions?.[0]?.args).toEqual(['ms-python.vscode-python-envs']); + }); + + it('marks global manager not recommended only when a global env is selected', () => { + const base = { + pythonAvailable: true, + enableAiFeatures: false, + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [] as [], + playbooks: [] as [], + }; + const unselected = model.buildSnapshot({ + ...base, + envManagers: [ + { + id: 'ms-python:system', + name: 'Global', + isGlobal: true, + environments: [{ id: 'g1', label: 'python', version: '3.12', selected: false }], + }, + ], + }); + const globalNode = unselected.sections.find((s) => s.id === 'envManagers')?.nodes[0]; + expect(globalNode?.description).toBeUndefined(); + expect(globalNode?.warning).toBe(false); + + const selected = model.buildSnapshot({ + ...base, + envManagers: [ + { + id: 'ms-python:system', + name: 'Global', + isGlobal: true, + environments: [{ id: 'g1', label: 'python', version: '3.12', selected: true }], + }, + ], + }); + const selectedGlobal = selected.sections.find((s) => s.id === 'envManagers')?.nodes[0]; + expect(selectedGlobal?.description).toBe('not recommended'); + expect(selectedGlobal?.warning).toBe(true); + }); + + it('shows ansible-navigator missing EE message with install action', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + executionEnvironmentsError: 'ansible-navigator not found in PATH', + }); + const ee = snap.sections.find((s) => s.id === 'executionEnvironments')?.nodes[0]; + expect(ee?.id).toBe('ee-navigator-missing'); + expect(ee?.actions?.[0]?.command).toBe('ansibleDevToolsPackages.install'); + }); + + it('shows empty EE description and creator message nodes', () => { + const emptyEe = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + executionEnvironments: [], + }); + expect( + emptyEe.sections.find((s) => s.id === 'executionEnvironments')?.nodes[0], + ).toMatchObject({ + id: 'ee-empty', + description: 'Build or pull an EE image', + }); + + const creator = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + creator: { status: 'not-installed', schema: null }, + }); + const node = creator.sections.find((s) => s.id === 'creator')?.nodes[0]; + expect(node?.label).toBe('ansible-creator not found'); + expect(node?.actions?.[0]?.command).toBe('ansibleDevToolsPackages.install'); + }); + + it('AI skills: empty source shows URL hint; no skills shows root message', () => { + const none = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: true, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + skillSources: [ + { + id: 'ai-forge', + type: 'github', + url: 'https://example.com/skills', + trust: 'community', + }, + ], + skills: [], + }); + expect(none.sections.find((s) => s.id === 'aiSkills')?.nodes[0]?.id).toBe('skills-none'); + + const mixed = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: true, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '25.1.0' }], + hasDevTools: true, + collections: [], + playbooks: [], + skillSources: [ + { + id: 'empty-src', + type: 'github', + url: 'https://example.com/empty', + trust: 'community', + }, + { + id: 'full-src', + type: 'builtin', + url: '', + trust: 'certified', + }, + ], + skills: [ + { + id: 'full-src/mod/skill', + source: 'full-src', + module: 'mod', + name: 'Skill', + description: 'd', + triggers: [], + category: 'domain', + trust: 'certified', + tags: [], + }, + ], + }); + const emptySrc = mixed.sections + .find((s) => s.id === 'aiSkills') + ?.nodes.find((n) => n.id === 'skill-src-empty-src'); + expect(emptySrc?.children?.[0]?.label).toContain('https://example.com/empty'); + }); + + it('buildEeDetailNodes builds info and package categories', () => { + const nodes = model.buildEeDetailNodes( + 'ee-1', + 'img:latest', + { + ansible_version: { details: '2.16' }, + image_name: 'img:latest', + python_packages: { + details: [{ name: 'ansible-core', version: '2.16.0', summary: 'core' }], + }, + }, + [{ name: 'bash', version: '5.0' }], + ); + expect(nodes.map((n) => n.label)).toEqual(['Info', 'Python Packages', 'System Packages']); + expect(nodes[1]?.children?.[0]?.actions?.[0]?.args).toEqual([ + 'img:latest', + 'ansible-core', + 'python', + ]); + }); + + it('buildPluginTypeNodes and patchNodeChildren resolve lazy rows', () => { + const children = model.buildPluginTypeNodes( + 'col-1', + { + modules: [ + { + name: 'copy', + fullName: 'ansible.builtin.copy', + shortDescription: 'Copy files', + }, + ], + }, + { namespace: 'ansible', name: 'builtin', version: '1.0.0' }, + 'galaxy', + true, + ); + expect(children[0]?.label).toBe('modules'); + expect(children[0]?.children?.[0]?.actions?.[0]?.command).toBe( + 'ansibleCollectionSources.showGalaxyPluginDoc', + ); + expect(children[0]?.children?.[0]?.actions?.[1]?.command).toBe( + 'ansibleCollectionSources.galaxyPluginAiSummary', + ); + + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '1' }], + hasDevTools: true, + collections: [], + playbooks: [], + executionEnvironments: [ + { + created: 't', + execution_environment: true, + full_name: 'ee:latest', + image_id: 'id', + }, + ], + }); + const eeId = snap.sections.find((s) => s.id === 'executionEnvironments')?.nodes[0]?.id; + expect(eeId).toBeTruthy(); + if (!eeId) { + return; + } + const patched = model.patchNodeChildren(snap, eeId, children); + const ee = patched.sections.find((s) => s.id === 'executionEnvironments')?.nodes[0]; + expect(ee?.lazyChildren).toBe(false); + expect(ee?.children?.[0]?.label).toBe('modules'); + }); + + it('falls back to tool name when MCP description is empty', () => { + const tools = assembleAiTools( + [ + { + category: 'discovery', + tool: { name: 'list_collections', description: '' }, + examplePrompt: 'List collections', + toolInfo: {}, + }, + ], + { discovery: 'Discovery' }, + ); + expect(tools?.[0]?.label).toBe('list_collections'); + }); +}); diff --git a/packages/services/test/services/CommandService.test.ts b/packages/services/test/services/CommandService.test.ts index c16223f053..ecdef3cffd 100644 --- a/packages/services/test/services/CommandService.test.ts +++ b/packages/services/test/services/CommandService.test.ts @@ -117,12 +117,14 @@ describe('CommandService', () => { ); }); - afterEach(() => { + afterEach(async () => { if (previousWorkspace === undefined) { delete process.env.ANSIBLE_ENV_WORKSPACE; } else { process.env.ANSIBLE_ENV_WORKSPACE = previousWorkspace; } + const { clearCachedEnvironment } = await import('../../src/EnvironmentCache'); + clearCachedEnvironment(); fs.rmSync(tmpDir, { recursive: true, force: true }); vi.resetModules(); }); @@ -606,13 +608,41 @@ describe('CommandService', () => { expect(result.stdout).toBe('built'); }); - it('getToolPath falls through to PATH when not in binDir or cache', async () => { + it('getToolPath does not leak PATH when active binDir lacks the tool', async () => { const binDir = path.join(tmpDir, 'empty-venv', 'bin'); fs.mkdirSync(binDir, { recursive: true }); fs.writeFileSync(path.join(binDir, 'python'), ''); const { cacheSelectedEnvironment } = await import('../../src/EnvironmentCache'); cacheSelectedEnvironment(path.join(binDir, 'python')); + execFileImpl.mockImplementation( + ( + file: string, + args: string[], + optsOrCb: + | Record + | ((err: Error | null, stdout?: string, stderr?: string) => void), + maybeCb?: (err: Error | null, stdout?: string, stderr?: string) => void, + ) => { + const cb = typeof optsOrCb === 'function' ? optsOrCb : maybeCb; + if ((file === 'which' || file === 'where') && args.includes('some-tool')) { + cb?.(null, '/home/user/.local/bin/some-tool\n', ''); + return; + } + cb?.(null, 'out\n', ''); + }, + ); + + const { CommandService } = await import('../../src/CommandService'); + const svc = CommandService.getInstance(); + const result = await svc.getToolPath('some-tool'); + expect(result).toBeNull(); + }); + + it('getToolPath falls through to PATH when no binDir is available', async () => { + const { clearCachedEnvironment } = await import('../../src/EnvironmentCache'); + clearCachedEnvironment(); + execFileImpl.mockImplementation( ( file: string, @@ -633,6 +663,8 @@ describe('CommandService', () => { const { CommandService } = await import('../../src/CommandService'); const svc = CommandService.getInstance(); + // Ensure no resolver injects a binDir + svc.setBinDirResolver(() => Promise.resolve(null)); const result = await svc.getToolPath('some-tool'); expect(result).toBe('/usr/bin/some-tool'); }); diff --git a/packages/services/test/services/DevToolsService.terminal.test.ts b/packages/services/test/services/DevToolsService.terminal.test.ts index 5bcb8811eb..c09947cff3 100644 --- a/packages/services/test/services/DevToolsService.terminal.test.ts +++ b/packages/services/test/services/DevToolsService.terminal.test.ts @@ -11,12 +11,18 @@ ansible-navigator 24.2.0 const mocks = vi.hoisted(() => { const mockRunTool = vi.fn(); const mockGetToolPath = vi.fn(); + const mockGetBinDir = vi.fn().mockResolvedValue(null); + const mockRunCommandArgs = vi.fn(); return { mockRunTool, mockGetToolPath, + mockGetBinDir, + mockRunCommandArgs, getCommandService: vi.fn(() => ({ runTool: mockRunTool, getToolPath: mockGetToolPath, + getBinDir: mockGetBinDir, + runCommandArgs: mockRunCommandArgs, })), }; }); @@ -25,6 +31,13 @@ vi.mock('../../src/CommandService', () => ({ getCommandService: mocks.getCommandService, })); +vi.mock('../../src/EnvironmentCache', () => ({ + getCachedEnvironment: vi.fn().mockReturnValue(null), + getCachedBinDir: vi.fn().mockReturnValue(null), + getCachedToolPath: vi.fn().mockReturnValue(null), + findExecutableWithCache: vi.fn().mockResolvedValue(null), +})); + import { DevToolsService } from '../../src/DevToolsService'; interface TerminalMockResult { @@ -70,11 +83,16 @@ describe('DevToolsService terminal fallback (Layer 3)', () => { resetDevToolsSingleton(); mocks.mockRunTool.mockReset(); mocks.mockGetToolPath.mockReset(); + mocks.mockGetBinDir.mockReset(); mocks.getCommandService.mockClear(); mocks.mockGetToolPath.mockResolvedValue('/mock/bin/adt'); + mocks.mockGetBinDir.mockResolvedValue(null); + mocks.mockRunCommandArgs.mockReset(); mocks.getCommandService.mockImplementation(() => ({ runTool: mocks.mockRunTool, getToolPath: mocks.mockGetToolPath, + getBinDir: mocks.mockGetBinDir, + runCommandArgs: mocks.mockRunCommandArgs, })); }); @@ -115,9 +133,10 @@ describe('DevToolsService terminal fallback (Layer 3)', () => { name: 'Install ansible-dev-tools', show: true, }); - expect(mockSendCommand).toHaveBeenCalledWith('pip install ansible-dev-tools', { - waitForCompletion: true, - }); + expect(mockSendCommand).toHaveBeenCalledWith( + expect.stringContaining('pip install ansible-dev-tools'), + { waitForCompletion: true }, + ); expect(svc.hasPackages()).toBe(true); expect(svc.isLoaded()).toBe(true); }); @@ -149,6 +168,37 @@ describe('DevToolsService terminal fallback (Layer 3)', () => { expect(factory).not.toHaveBeenCalled(); }); + it('falls back to terminal when Layer 2 packageInstaller throws', async () => { + simulateVSCode(); + const { factory, mockSendCommand } = createTerminalMock({ + exitCode: 0, + success: true, + }); + DevToolsService.setTerminalServiceFactory(factory); + + mocks.mockRunTool.mockResolvedValue({ + exitCode: 0, + stdout: ADT_OUTPUT, + stderr: '', + }); + + const installer = vi + .fn() + .mockRejectedValue(new Error('No Python environment selected')); + const svc = DevToolsService.getInstance(); + svc.setPackageInstaller(installer); + + await svc.install(); + + expect(installer).toHaveBeenCalledOnce(); + expect(factory).toHaveBeenCalledOnce(); + expect(mockSendCommand).toHaveBeenCalledWith( + expect.stringContaining('pip install ansible-dev-tools'), + { waitForCompletion: true }, + ); + expect(svc.hasPackages()).toBe(true); + }); + it('refreshes packages after terminal install completes', async () => { simulateVSCode(); const { factory } = createTerminalMock({ exitCode: 0, success: true }); diff --git a/packages/services/test/services/DevToolsService.test.ts b/packages/services/test/services/DevToolsService.test.ts index 97acef44ce..67badd2c61 100644 --- a/packages/services/test/services/DevToolsService.test.ts +++ b/packages/services/test/services/DevToolsService.test.ts @@ -1,3 +1,6 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { describe, it, expect, vi, beforeEach } from 'vitest'; const ADT_OUTPUT = `ansible-builder 24.2.0 @@ -11,12 +14,18 @@ ansible-navigator 24.2.0 const mocks = vi.hoisted(() => { const mockRunTool = vi.fn(); const mockGetToolPath = vi.fn(); + const mockGetBinDir = vi.fn().mockResolvedValue(null); + const mockRunCommandArgs = vi.fn(); return { mockRunTool, mockGetToolPath, + mockGetBinDir, + mockRunCommandArgs, getCommandService: vi.fn(() => ({ runTool: mockRunTool, getToolPath: mockGetToolPath, + getBinDir: mockGetBinDir, + runCommandArgs: mockRunCommandArgs, })), }; }); @@ -25,6 +34,13 @@ vi.mock('../../src/CommandService', () => ({ getCommandService: mocks.getCommandService, })); +vi.mock('../../src/EnvironmentCache', () => ({ + getCachedEnvironment: vi.fn().mockReturnValue(null), + getCachedBinDir: vi.fn().mockReturnValue(null), + getCachedToolPath: vi.fn().mockReturnValue(null), + findExecutableWithCache: vi.fn().mockResolvedValue(null), +})); + import { DevToolsService } from '../../src/DevToolsService'; /** Clears the DevToolsService singleton and terminal factory for test isolation. */ @@ -40,11 +56,16 @@ describe('DevToolsService', () => { resetDevToolsSingleton(); mocks.mockRunTool.mockReset(); mocks.mockGetToolPath.mockReset(); + mocks.mockGetBinDir.mockReset(); + mocks.mockRunCommandArgs.mockReset(); mocks.getCommandService.mockClear(); mocks.mockGetToolPath.mockResolvedValue('/mock/bin/adt'); + mocks.mockGetBinDir.mockResolvedValue(null); mocks.getCommandService.mockImplementation(() => ({ runTool: mocks.mockRunTool, getToolPath: mocks.mockGetToolPath, + getBinDir: mocks.mockGetBinDir, + runCommandArgs: mocks.mockRunCommandArgs, })); }); @@ -174,7 +195,40 @@ describe('DevToolsService', () => { expect((DevToolsService as any).terminalServiceFactory).toBe(factory); }); - it('install delegates to packageInstaller when set', async () => { + it('install prefers pip exec when selected python is known', async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ansible-adt-venv-bin-')); + const python = path.join(binDir, 'python3'); + try { + fs.writeFileSync(python, ''); + mocks.mockGetBinDir.mockResolvedValue(binDir); + mocks.mockRunCommandArgs.mockResolvedValue({ + exitCode: 0, + stdout: 'Successfully installed ansible-dev-tools', + stderr: '', + }); + mocks.mockRunTool.mockResolvedValue({ + exitCode: 0, + stdout: ADT_OUTPUT, + stderr: '', + }); + const installer = vi.fn().mockResolvedValue(undefined); + const svc = DevToolsService.getInstance(); + svc.setPackageInstaller(installer); + await svc.install(); + expect(mocks.mockRunCommandArgs).toHaveBeenCalledWith( + python, + ['-m', 'pip', 'install', 'ansible-dev-tools'], + { timeout: 600_000 }, + ); + expect(installer).not.toHaveBeenCalled(); + expect(svc.hasPackages()).toBe(true); + } finally { + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it('install delegates to packageInstaller when no python path is known', async () => { + mocks.mockGetBinDir.mockResolvedValue(null); mocks.mockRunTool.mockResolvedValue({ exitCode: 0, stdout: ADT_OUTPUT, diff --git a/packages/services/test/sidebar/registry.test.ts b/packages/services/test/sidebar/registry.test.ts new file mode 100644 index 0000000000..2aa40cd35d --- /dev/null +++ b/packages/services/test/sidebar/registry.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { SECTION_REGISTRY, SidebarModel } from '../../src/SidebarModel'; + +describe('SECTION_REGISTRY', () => { + const model = new SidebarModel(); + + it('skeleton section ids match full snapshot order (AI + Lightspeed on)', () => { + const input = { + pythonAvailable: true, + enableAiFeatures: true, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '1' }], + hasDevTools: true, + collections: [], + playbooks: [], + lightspeedEnabled: true, + }; + const full = model.buildSnapshot(input); + const skeleton = model.buildSkeletonSnapshot({ + enableAiFeatures: true, + lightspeedEnabled: true, + }); + + expect(skeleton.sections.map((s) => s.id)).toEqual(full.sections.map((s) => s.id)); + expect(skeleton.sections.map((s) => s.title)).toEqual(full.sections.map((s) => s.title)); + expect(skeleton.sections.every((s) => s.loading)).toBe(true); + }); + + it('lists core sections before optional AI / Lightspeed', () => { + const ids = SECTION_REGISTRY.map((d) => d.id); + expect(ids.slice(0, 7)).toEqual([ + 'envManagers', + 'devTools', + 'collections', + 'collectionSources', + 'executionEnvironments', + 'creator', + 'playbooks', + ]); + expect(ids.slice(7)).toEqual(['aiTools', 'aiSkills', 'lightspeed']); + }); +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 73cf8e30b8..dad055ccbd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,7 +19,8 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "playground:sidebar": "vite --config playground/vite.config.ts" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/packages/ui/playground/mock-snapshot.ts b/packages/ui/playground/mock-snapshot.ts new file mode 100644 index 0000000000..2da18f601a --- /dev/null +++ b/packages/ui/playground/mock-snapshot.ts @@ -0,0 +1,236 @@ +import type { SidebarSection, SidebarSnapshot, SidebarTreeNode } from '../src/sidebar/types'; + +const pluginActions = (fqcn: string): SidebarTreeNode['actions'] => [ + { + id: `${fqcn}-docs`, + label: 'View Documentation', + icon: 'book', + command: 'ansibleDevToolsCollections.showPluginDoc', + }, + { + id: `${fqcn}-ai`, + label: 'Generate AI Summary', + icon: 'sparkle', + command: 'ansibleDevToolsCollections.aiPluginSummary', + }, +]; + +const collectionActions = (name: string): SidebarTreeNode['actions'] => [ + { + id: `${name}-ai`, + label: 'Generate AI Summary', + icon: 'sparkle', + command: 'ansibleDevToolsCollections.aiCollectionSummary', + }, +]; + +const playbookActions = (id: string): SidebarTreeNode['actions'] => [ + { + id: `${id}-run`, + label: 'Run Playbook', + icon: 'play', + command: 'ansiblePlaybooks.run', + }, + { + id: `${id}-progress`, + label: 'Run with Progress Viewer', + icon: 'graph', + command: 'ansiblePlaybooks.runWithProgress', + }, + { + id: `${id}-ai`, + label: 'Generate AI Summary', + icon: 'sparkle', + command: 'ansiblePlaybooks.aiSummary', + }, +]; + +const healthyEnvManagers: SidebarSection = { + id: 'envManagers', + title: 'Environment Managers', + nodes: [ + { + id: 'venv', + label: 'Venv', + icon: 'folder', + children: [ + { + id: 'venv-ansible', + label: 'ansible-dev', + description: '3.12.8', + icon: 'check', + selected: true, + }, + { + id: 'venv-other', + label: 'scratch', + description: '3.11.9', + icon: 'symbol-misc', + }, + ], + }, + { + id: 'conda', + label: 'Conda', + icon: 'folder', + children: [ + { + id: 'conda-base', + label: 'base', + description: '3.10.14', + icon: 'symbol-misc', + }, + ], + }, + { + id: 'global', + label: 'Global', + icon: 'globe', + warning: true, + description: 'not recommended', + children: [ + { + id: 'global-sys', + label: '/usr/bin/python3', + description: '3.13.0', + icon: 'symbol-misc', + warning: true, + }, + ], + }, + ], +}; + +const issueEnvManagers: SidebarSection = { + id: 'envManagers', + title: 'Environment Managers', + severity: 'warning', + welcome: 'No Python extension detected.', + welcomeActions: [ + { + id: 'install-python', + label: 'Install Python Extension', + command: 'workbench.extensions.search', + }, + ], + nodes: [], +}; + +const healthyDevTools: SidebarSection = { + id: 'devTools', + title: 'Ansible Dev Tools', + nodes: [ + { id: 'pkg-ansible', label: 'ansible-core', description: '2.18.3', icon: 'package' }, + { id: 'pkg-lint', label: 'ansible-lint', description: '25.1.2', icon: 'package' }, + { id: 'pkg-creator', label: 'ansible-creator', description: '25.3.0', icon: 'package' }, + { + id: 'pkg-ade', + label: 'ansible-dev-environment', + description: '25.4.0', + icon: 'package', + }, + { id: 'pkg-navigator', label: 'ansible-navigator', description: '25.4.0', icon: 'package' }, + ], +}; + +const issueDevTools: SidebarSection = { + id: 'devTools', + title: 'Ansible Dev Tools', + severity: 'error', + welcome: 'No ansible-dev-tools packages found.', + welcomeActions: [ + { + id: 'install-adt', + label: 'Install ansible-dev-tools', + command: 'ansibleDevToolsPackages.install', + }, + ], + nodes: [], +}; + +const collections: SidebarSection = { + id: 'collections', + title: 'Installed Collections', + nodes: [ + { + id: 'col-posix', + label: 'ansible.posix', + description: '2.0.0', + icon: 'package', + actions: collectionActions('ansible.posix'), + children: [ + { + id: 'plugin-synchronize', + label: 'synchronize', + description: 'module', + icon: 'symbol-method', + actions: pluginActions('ansible.posix.synchronize'), + }, + { + id: 'plugin-selinux', + label: 'selinux', + description: 'module', + icon: 'symbol-method', + actions: pluginActions('ansible.posix.selinux'), + }, + ], + }, + { + id: 'col-general', + label: 'community.general', + description: '10.3.0', + icon: 'package', + actions: collectionActions('community.general'), + children: [ + { + id: 'plugin-ini_file', + label: 'ini_file', + description: 'module', + icon: 'symbol-method', + actions: pluginActions('community.general.ini_file'), + }, + ], + }, + ], +}; + +const playbooks: SidebarSection = { + id: 'playbooks', + title: 'Playbooks', + nodes: [ + { + id: 'pb-site', + label: 'site.yml', + icon: 'file', + actions: playbookActions('site'), + }, + { + id: 'pb-deploy', + label: 'deploy.yml', + icon: 'file', + actions: playbookActions('deploy'), + }, + ], +}; + +export type PlaygroundScenario = 'healthy' | 'missingPython' | 'missingAdt'; + +export function buildMockSnapshot(scenario: PlaygroundScenario): SidebarSnapshot { + switch (scenario) { + case 'missingPython': + return { + sections: [issueEnvManagers, healthyDevTools, collections, playbooks], + suggestedOpenSectionId: 'envManagers', + }; + case 'missingAdt': + return { + sections: [healthyEnvManagers, issueDevTools, collections, playbooks], + suggestedOpenSectionId: 'devTools', + }; + default: + return { + sections: [healthyEnvManagers, healthyDevTools, collections, playbooks], + suggestedOpenSectionId: null, + }; + } +} diff --git a/packages/ui/playground/sidebar-navtree.html b/packages/ui/playground/sidebar-navtree.html new file mode 100644 index 0000000000..0d4fdbcc1f --- /dev/null +++ b/packages/ui/playground/sidebar-navtree.html @@ -0,0 +1,16 @@ + + + + + + Ansible sidebar NavTree — fidelity spike + + + +
+ + + diff --git a/packages/ui/playground/sidebar-navtree.tsx b/packages/ui/playground/sidebar-navtree.tsx new file mode 100644 index 0000000000..c46893eaa9 --- /dev/null +++ b/packages/ui/playground/sidebar-navtree.tsx @@ -0,0 +1,108 @@ +import { StrictMode, useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { SidebarShell } from '../src/sidebar/SidebarShell'; +import type { SidebarSectionId } from '../src/sidebar/types'; +import { buildMockSnapshot, type PlaygroundScenario } from './mock-snapshot'; +import '../src/styles/tokens.css'; +import '../src/styles/vscode-host.css'; +import '../src/sidebar/sidebar.css'; +import './vscode-theme.css'; + +type Theme = 'dark' | 'light'; + +function PlaygroundApp() { + const [theme, setTheme] = useState('dark'); + const [scenario, setScenario] = useState('healthy'); + const [openSectionId, setOpenSectionId] = useState(null); + const [lastAction, setLastAction] = useState(null); + + const snapshot = buildMockSnapshot(scenario); + + useEffect(() => { + document.documentElement.classList.remove('theme-dark', 'theme-light'); + document.documentElement.classList.add(`theme-${theme}`); + }, [theme]); + + useEffect(() => { + setOpenSectionId(snapshot.suggestedOpenSectionId ?? null); + }, [scenario, snapshot.suggestedOpenSectionId]); + + return ( +
+
+

Ansible sidebar NavTree

+

+ Phase 0 fidelity spike — accordion shell + mock trees. Compare to the native + Ansible Activity Bar drawer. +

+
+ +
+ + + +
+ +
+
+ setLastAction(command)} + onNodeAction={(command, nodeId) => + setLastAction(`${command} (${nodeId})`) + } + /> +
+ +
+
+ ); +} + +const root = document.getElementById('root'); +if (root) { + createRoot(root).render( + + + , + ); +} diff --git a/packages/ui/playground/vite.config.ts b/packages/ui/playground/vite.config.ts new file mode 100644 index 0000000000..b160176601 --- /dev/null +++ b/packages/ui/playground/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +/** Dev-only app config for the sidebar NavTree fidelity spike. */ +export default defineConfig({ + root: resolve(__dirname), + server: { + host: '127.0.0.1', + port: 5174, + open: false, + strictPort: true, + }, + build: { + outDir: resolve(__dirname, '../dist-playground'), + emptyOutDir: true, + rollupOptions: { + input: resolve(__dirname, 'sidebar-navtree.html'), + }, + }, +}); diff --git a/packages/ui/playground/vscode-theme.css b/packages/ui/playground/vscode-theme.css new file mode 100644 index 0000000000..2459c17f87 --- /dev/null +++ b/packages/ui/playground/vscode-theme.css @@ -0,0 +1,163 @@ +/* + * Standalone --vscode-* presets so the playground can use vscode-host.css + * without running inside VS Code. + */ + +html.theme-dark { + color-scheme: dark; + --vscode-editor-background: #1e1e1e; + --vscode-editor-foreground: #cccccc; + --vscode-sideBar-background: #252526; + --vscode-sideBar-foreground: #bbbbbb; + --vscode-sideBarSectionHeader-foreground: #bbbbbb; + --vscode-sideBarSectionHeader-background: rgba(128, 128, 128, 0.2); + --vscode-sideBarTitle-foreground: #bbbbbb; + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-activeSelectionBackground: #094771; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-descriptionForeground: #9d9d9d; + --vscode-disabledForeground: #6e6e6e; + --vscode-textLink-foreground: #4daafc; + --vscode-panel-border: #3c3c3c; + --vscode-widget-border: #3c3c3c; + --vscode-focusBorder: #007fd4; + --vscode-button-background: #0e639c; + --vscode-button-hoverBackground: #1177bb; + --vscode-testing-iconPassed: #4ec9b0; + --vscode-editorWarning-foreground: #cca700; + --vscode-editorError-foreground: #f44747; + /* stylelint-disable-next-line value-keyword-case -- proper font-family names */ + --vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --vscode-font-size: 13px; + /* stylelint-disable-next-line value-keyword-case -- proper font-family names */ + --vscode-editor-font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace; +} + +html.theme-light { + color-scheme: light; + --vscode-editor-background: #ffffff; + --vscode-editor-foreground: #333333; + --vscode-sideBar-background: #f3f3f3; + --vscode-sideBar-foreground: #6f6f6f; + --vscode-sideBarSectionHeader-foreground: #6f6f6f; + --vscode-sideBarSectionHeader-background: rgba(128, 128, 128, 0.2); + --vscode-sideBarTitle-foreground: #6f6f6f; + --vscode-list-hoverBackground: #e8e8e8; + --vscode-list-activeSelectionBackground: #0060c0; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-descriptionForeground: #616161; + --vscode-disabledForeground: #a0a0a0; + --vscode-textLink-foreground: #006ab1; + --vscode-panel-border: #e0e0e0; + --vscode-widget-border: #c8c8c8; + --vscode-focusBorder: #0090f1; + --vscode-button-background: #0078d4; + --vscode-button-hoverBackground: #026ec1; + --vscode-testing-iconPassed: #388a34; + --vscode-editorWarning-foreground: #bf8803; + --vscode-editorError-foreground: #e51400; + /* stylelint-disable-next-line value-keyword-case -- proper font-family names */ + --vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --vscode-font-size: 13px; + /* stylelint-disable-next-line value-keyword-case -- proper font-family names */ + --vscode-editor-font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); +} + +.playground-page { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + max-width: 960px; + margin: 0 auto; +} + +.playground-header h1 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 600; +} + +.playground-header p { + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 13px; +} + +.playground-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.playground-controls label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.playground-controls select, +.playground-controls button { + font: inherit; + font-size: 12px; + padding: 4px 10px; + border: 1px solid var(--vscode-panel-border); + border-radius: 2px; + background: var(--vscode-sideBar-background); + color: var(--vscode-editor-foreground); + cursor: pointer; +} + +.playground-controls button:hover { + background: var(--vscode-list-hoverBackground); +} + +.playground-stage { + display: flex; + gap: 24px; + align-items: flex-start; +} + +.playground-sidebar-frame { + width: 280px; + height: 520px; + flex-shrink: 0; + box-shadow: 0 0 0 1px var(--vscode-panel-border); + overflow: hidden; +} + +.playground-notes { + flex: 1; + min-width: 200px; + font-size: 12px; + line-height: 1.5; + color: var(--vscode-descriptionForeground); +} + +.playground-notes ul { + margin: 8px 0 0; + padding-left: 18px; +} + +.playground-notes code { + font-family: var(--vscode-editor-font-family); + font-size: 11px; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3f3b6e2295..df10ddc7bc 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -58,3 +58,20 @@ export type { export { PlaybookConfigView } from './views/PlaybookConfigView'; export { PlaybookProgressView } from './views/PlaybookProgressView'; export { DiagnosticsView } from './views/DiagnosticsView'; + +export { SidebarShell, SidebarTree } from './sidebar'; +export { SidebarNavTreeView } from './views/SidebarNavTreeView'; +export type { SidebarNavTreeViewProps, SidebarNavTreeHost } from './views/SidebarNavTreeView'; +export type { + SidebarShellProps, + SidebarTreeProps, + SidebarSectionId, + SidebarNodeAction, + SidebarNodeExpand, + SidebarTreeNode, + SidebarSectionSeverity, + SidebarWelcomeAction, + SidebarSection, + SidebarSnapshot, + SidebarEnvManagerInput, +} from './sidebar'; diff --git a/packages/ui/src/sidebar/SidebarShell.tsx b/packages/ui/src/sidebar/SidebarShell.tsx new file mode 100644 index 0000000000..9562e1637b --- /dev/null +++ b/packages/ui/src/sidebar/SidebarShell.tsx @@ -0,0 +1,202 @@ +import { useState } from 'react'; +import type { FocusEvent, MouseEvent } from 'react'; +import type { + SidebarNodeAction, + SidebarNodeExpand, + SidebarSectionId, + SidebarSnapshot, +} from './types'; +import { SidebarTree } from './SidebarTree'; + +export interface SidebarShellProps { + snapshot: SidebarSnapshot; + /** Controlled open section; `null` = all collapsed. */ + openSectionId: SidebarSectionId | null; + onOpenSectionChange: (id: SidebarSectionId | null) => void; + /** Fired when a welcome action button is clicked. */ + onWelcomeAction?: (command: string, sectionId: SidebarSectionId, args?: unknown[]) => void; + /** Fired when a header or inline tree-row action is clicked (host runs the command). */ + onNodeAction?: (command: string, nodeId: string, args?: unknown[]) => void; + /** Lazy-load children when a collapsible row is first expanded. */ + onNodeExpand?: (nodeId: string, expand: SidebarNodeExpand) => void; +} + +interface FloatingTip { + text: string; + x: number; + y: number; +} + +/** + * Accordion sidebar NavTree: one section open at a time, all closed by default. + * Host-agnostic React UI for VS Code WebviewView and Electron. + * @param root0 - Component props. + * @param root0.snapshot - Current NavTree snapshot (sections and suggested open id). + * @param root0.openSectionId - Controlled open section; `null` = all collapsed. + * @param root0.onOpenSectionChange - Called when the open section changes. + * @param root0.onWelcomeAction - Fired when a welcome action button is clicked. + * @param root0.onNodeAction - Fired when a header or inline tree-row action is clicked. + * @param root0.onNodeExpand - Lazy-load children when a collapsible row is first expanded. + * @returns The rendered accordion sidebar shell. + */ +export function SidebarShell({ + snapshot, + openSectionId, + onOpenSectionChange, + onWelcomeAction, + onNodeAction, + onNodeExpand, +}: SidebarShellProps) { + const [tip, setTip] = useState(null); + + const toggle = (id: SidebarSectionId) => { + onOpenSectionChange(openSectionId === id ? null : id); + }; + + const runHeaderAction = (e: MouseEvent, action: SidebarNodeAction) => { + e.stopPropagation(); + e.preventDefault(); + setTip(null); + onNodeAction?.(action.command, action.id, action.args); + }; + + const showHeaderTip = (el: HTMLButtonElement, text: string) => { + const rect = el.getBoundingClientRect(); + setTip({ + text, + x: rect.left + rect.width / 2, + y: rect.top, + }); + }; + + return ( +
+ {snapshot.sections.map((section) => { + const open = openSectionId === section.id; + const headerId = `sidebar-section-${section.id}`; + const panelId = `sidebar-panel-${section.id}`; + + return ( +
+
+ + {section.headerActions?.length ? ( + + {section.headerActions.map((action) => ( + + ))} + + ) : null} +
+ {open ? ( +
+ {section.nodes.length === 0 && + (section.welcome || section.welcomeActions?.length) ? ( +
+ {section.welcome ? ( +

+ {section.welcome} +

+ ) : null} + {section.welcomeActions?.length ? ( +
+ {section.welcomeActions.map((action) => ( + + ))} +
+ ) : null} +
+ ) : null} + +
+ ) : null} +
+ ); + })} + {tip ? ( +
+ {tip.text} +
+ ) : null} +
+ ); +} diff --git a/packages/ui/src/sidebar/SidebarTree.tsx b/packages/ui/src/sidebar/SidebarTree.tsx new file mode 100644 index 0000000000..795e0cad96 --- /dev/null +++ b/packages/ui/src/sidebar/SidebarTree.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react'; +import type { KeyboardEvent, MouseEvent } from 'react'; +import type { SidebarNodeAction, SidebarNodeExpand, SidebarTreeNode } from './types'; + +export interface SidebarTreeProps { + nodes: SidebarTreeNode[]; + /** Depth for indentation (root = 0). */ + depth?: number; + onNodeAction?: (command: string, nodeId: string, args?: unknown[]) => void; + /** Lazy-load children when a collapsible row is first expanded. */ + onNodeExpand?: (nodeId: string, expand: SidebarNodeExpand) => void; +} + +/** + * Single expandable tree row for the sidebar NavTree. + * @param root0 - Component props. + * @param root0.node - Tree node to render. + * @param root0.depth - Nesting depth for indentation (root = 0). + * @param root0.onNodeAction - Fired when an inline action is clicked. + * @param root0.onNodeExpand - Lazy-load children when a collapsible row is first expanded. + * @returns The rendered tree row (and nested children when expanded). + */ +function TreeRow({ + node, + depth, + onNodeAction, + onNodeExpand, +}: { + node: SidebarTreeNode; + depth: number; + onNodeAction?: (command: string, nodeId: string, args?: unknown[]) => void; + onNodeExpand?: (nodeId: string, expand: SidebarNodeExpand) => void; +}) { + const expandable = Boolean(node.children?.length) || Boolean(node.lazyChildren); + const [expanded, setExpanded] = useState(Boolean(node.children?.length) && depth === 0); + const awaitingHost = + expanded && Boolean(node.lazyChildren) && !node.children?.length && Boolean(node.expand); + + const onToggle = () => { + if (!expandable) { + return; + } + const next = !expanded; + setExpanded(next); + if (next && node.lazyChildren && !node.children?.length && node.expand && onNodeExpand) { + onNodeExpand(node.id, node.expand); + } + }; + + /** Leaf rows mirror TreeItem.command: click runs the primary action. */ + const onRowActivate = () => { + if (expandable) { + onToggle(); + return; + } + const primary = node.actions?.[0]; + if (primary) { + onNodeAction?.(primary.command, node.id, primary.args); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.target !== e.currentTarget) { + return; + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onRowActivate(); + } + }; + + const runAction = (e: MouseEvent, action: SidebarNodeAction) => { + e.stopPropagation(); + onNodeAction?.(action.command, node.id, action.args); + }; + + const classes = [ + 'ansible-sidebar-tree-row', + node.warning ? 'is-warning' : '', + node.selected ? 'is-selected' : '', + node.actions?.length ? 'has-actions' : '', + ] + .filter(Boolean) + .join(' '); + + return ( +
  • +
    +
    + {expandable && expanded ? ( + awaitingHost ? ( +
      +
    • +
      +
      +
    • +
    + ) : ( + + ) + ) : null} +
  • + ); +} + +/** + * Shallow expandable tree for sidebar NavTree sections. + * Host-agnostic; no vscode imports. + * @param root0 - Component props. + * @param root0.nodes - Root-level tree nodes for this section. + * @param root0.depth - Nesting depth for indentation (root = 0). + * @param root0.onNodeAction - Fired when an inline action is clicked. + * @param root0.onNodeExpand - Lazy-load children when a collapsible row is first expanded. + * @returns The rendered tree list, or null when there are no nodes. + */ +export function SidebarTree({ nodes, depth = 0, onNodeAction, onNodeExpand }: SidebarTreeProps) { + if (nodes.length === 0) { + return null; + } + + return ( +
      + {nodes.map((node) => ( + + ))} +
    + ); +} diff --git a/packages/ui/src/sidebar/index.ts b/packages/ui/src/sidebar/index.ts new file mode 100644 index 0000000000..cdccf57c4c --- /dev/null +++ b/packages/ui/src/sidebar/index.ts @@ -0,0 +1,15 @@ +export { SidebarShell } from './SidebarShell'; +export type { SidebarShellProps } from './SidebarShell'; +export { SidebarTree } from './SidebarTree'; +export type { SidebarTreeProps } from './SidebarTree'; +export type { + SidebarSectionId, + SidebarNodeAction, + SidebarNodeExpand, + SidebarTreeNode, + SidebarSectionSeverity, + SidebarWelcomeAction, + SidebarSection, + SidebarSnapshot, + SidebarEnvManagerInput, +} from './types'; diff --git a/packages/ui/src/sidebar/sidebar.css b/packages/ui/src/sidebar/sidebar.css new file mode 100644 index 0000000000..77f565aa65 --- /dev/null +++ b/packages/ui/src/sidebar/sidebar.css @@ -0,0 +1,429 @@ +/* + * Sidebar hub — match native VS Code pane headers / tree. + * Theme colors come from --vscode-* (update live on theme change). + * + * Avoid color:!important on the shell — it forces bright sideBar.foreground + * onto chevrons/icons via inheritance and beats child color rules. + */ + +.ansible-sidebar-shell.ansible-ui { + --sidebar-font: var(--vscode-font-family, var(--ui-font-family)); + /* Native pane title is ~11px, not the editor/body 13px font-size */ + --sidebar-title-size: var(--vscode-font-body3-size, 11px); + --sidebar-row-size: var(--vscode-font-size, 13px); + --sidebar-title-fg: var( + --vscode-sideBarSectionHeader-foreground, + var(--vscode-sideBar-foreground) + ); + --sidebar-title-bg: var(--vscode-sideBarSectionHeader-background, transparent); + --sidebar-fg: var(--vscode-sideBar-foreground); + --sidebar-bg: var(--vscode-sideBar-background); + --sidebar-hover: var(--vscode-list-hoverBackground); + --sidebar-active: var(--vscode-list-activeSelectionBackground); + --sidebar-border: var(--vscode-sideBarSectionHeader-border, transparent); + /* Match heading / row text — not icon.foreground (often brighter) */ + --sidebar-icon: var(--sidebar-title-fg); + + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + /* Keep trailing section headers pinned; only the open body scrolls. */ + overflow: hidden; + background: var(--sidebar-bg); + color: var(--sidebar-fg); + font-family: var(--sidebar-font); + font-size: var(--sidebar-row-size); + line-height: normal; + border: none; +} + +.ansible-sidebar-loading { + padding: 12px; + color: var(--vscode-descriptionForeground, var(--sidebar-fg)); +} + +.ansible-sidebar-section { + border-bottom: 1px solid var(--sidebar-border); + flex-shrink: 0; +} + +/* + * Open middle sections consume leftover height so headers below stay at the + * bottom of the hub (native-like accordion fill). + */ +.ansible-sidebar-section.is-open { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +.ansible-sidebar-section-header { + display: flex; + align-items: center; + gap: 0; + width: 100%; + height: 22px; + min-height: 22px; + max-height: 22px; + padding: 0 8px 0 0; + box-sizing: border-box; + background: var(--sidebar-title-bg); + color: var(--sidebar-title-fg); +} + +.ansible-sidebar-section-header:hover { + background: var(--sidebar-hover); +} + +.ansible-sidebar-section-toggle { + display: flex; + align-items: center; + gap: 0; + flex: 1; + min-width: 0; + height: 22px; + margin: 0; + padding: 0 0 0 2px; + border: none; + background: transparent; + color: inherit; + font-family: inherit; + font-size: var(--sidebar-title-size); + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0; + cursor: pointer; + text-align: left; + line-height: 22px; +} + +.ansible-sidebar-section-toggle:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.ansible-sidebar-section-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 22px; + flex-shrink: 0; + margin: 0 2px; + color: inherit; +} + +.ansible-sidebar-section-chevron.codicon { + font-size: 16px; + line-height: 22px; +} + +.ansible-sidebar-section-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--sidebar-title-size); + font-weight: bold; + line-height: 22px; + color: var(--sidebar-title-fg); +} + +/* view/title actions — visible on header hover / focus-within */ +.ansible-sidebar-section-actions { + display: none; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: auto; + margin-right: 0; +} + +.ansible-sidebar-section-header:hover .ansible-sidebar-section-actions, +.ansible-sidebar-section-header:focus-within .ansible-sidebar-section-actions { + display: inline-flex; +} + +.ansible-sidebar-section-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin: 0; + padding: 2px; + box-sizing: content-box; + border: none; + border-radius: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +.ansible-sidebar-section-action .codicon { + font-size: 16px; + line-height: 16px; + color: inherit; +} + +.ansible-sidebar-section-action:hover { + background: var(--sidebar-active); +} + +.ansible-sidebar-section-action:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 0; +} + +/* + * Header-action hover label — position:fixed so it is not clipped by the + * 22px section header or narrow overflow parents (native title tooltips are). + */ +.ansible-sidebar-floating-tip { + position: fixed; + z-index: 1000; + transform: translate(-50%, calc(-100% - 6px)); + max-width: min(280px, calc(100vw - 16px)); + padding: 4px 8px; + border-radius: 3px; + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, transparent)); + background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background)); + color: var(--vscode-editorHoverWidget-foreground, var(--sidebar-fg)); + box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.36)); + font-size: 12px; + font-weight: 400; + line-height: 1.35; + white-space: nowrap; + pointer-events: none; +} + +.ansible-sidebar-section-body { + flex: 1 1 auto; + min-height: 0; + padding: 0; + overflow: auto; + color: var(--sidebar-fg); +} + +.ansible-sidebar-welcome { + padding: 8px 12px 12px; +} + +.ansible-sidebar-welcome-text { + margin: 0; + font-size: var(--sidebar-row-size); + font-weight: 400; + text-transform: none; + letter-spacing: 0; + line-height: 1.4; + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; +} + +.ansible-sidebar-welcome-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: 12px; +} + +.ansible-sidebar-welcome-btn { + display: inline-block; + margin: 0; + padding: 4px 12px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 2px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + font: inherit; + font-size: var(--sidebar-row-size); + font-weight: 400; + text-transform: none; + letter-spacing: 0; + cursor: pointer; + max-width: 100%; + text-align: center; +} + +.ansible-sidebar-welcome-btn:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.ansible-sidebar-welcome-btn:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.ansible-sidebar-tree { + list-style: none; + margin: 0; + padding: 0; +} + +.ansible-sidebar-tree-row-inner { + display: flex; + align-items: center; + gap: 0; + height: 22px; + min-height: 22px; + padding-right: 8px; + cursor: default; + outline: none; + font-size: var(--sidebar-row-size); + font-weight: 400; + text-transform: none; + letter-spacing: 0; + line-height: 22px; + color: var(--sidebar-fg); +} + +.ansible-sidebar-tree-row-inner:hover { + background: var(--sidebar-hover); +} + +.ansible-sidebar-tree-row-inner:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.ansible-sidebar-tree-row.is-selected > .ansible-sidebar-tree-row-inner { + background: var(--sidebar-active); + color: var(--vscode-list-activeSelectionForeground, var(--sidebar-fg)); +} + +.ansible-sidebar-tree-row.is-warning .ansible-sidebar-tree-label, +.ansible-sidebar-tree-row.is-warning .ansible-sidebar-tree-desc { + color: var(--vscode-editorWarning-foreground, var(--ui-warning)); +} + +/* Match native ThemeColor: charts.green / problemsWarningIcon.foreground */ +.ansible-sidebar-tree-row.is-selected + > .ansible-sidebar-tree-row-inner + > .ansible-sidebar-tree-icon.codicon-check { + color: var(--vscode-charts-green, #89d185); +} + +.ansible-sidebar-tree-row.is-warning + > .ansible-sidebar-tree-row-inner + > .ansible-sidebar-tree-icon { + color: var( + --vscode-problemsWarningIcon-foreground, + var(--vscode-editorWarning-foreground, var(--ui-warning)) + ); +} + +.ansible-sidebar-tree-twistie { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 22px; + flex-shrink: 0; + color: inherit; +} + +.ansible-sidebar-tree-twistie.codicon { + font-size: 16px; + line-height: 22px; +} + +.ansible-sidebar-tree-twistie.is-leaf { + visibility: hidden; +} + +.ansible-sidebar-tree-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 22px; + flex-shrink: 0; + margin-right: 2px; + color: inherit; + text-align: center; +} + +.ansible-sidebar-tree-icon.codicon { + font-size: 16px; + line-height: 22px; +} + +/* Prefer the title (like native TreeItem.label); truncate the summary first. */ +.ansible-sidebar-tree-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: inherit; +} + +.ansible-sidebar-tree-desc { + flex: 0 1 auto; + max-width: 40%; + min-width: 0; + margin-left: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.7; + color: var(--vscode-descriptionForeground); +} + +.ansible-sidebar-tree-actions { + display: none; + align-items: center; + gap: 4px; + margin-left: auto; + flex-shrink: 0; + padding-left: 4px; +} + +.ansible-sidebar-tree-row-inner:hover .ansible-sidebar-tree-actions, +.ansible-sidebar-tree-row-inner:focus-within .ansible-sidebar-tree-actions { + display: inline-flex; +} + +.ansible-sidebar-tree-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin: 0; + padding: 2px; + box-sizing: content-box; + border: none; + border-radius: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +.ansible-sidebar-tree-action .codicon { + font-size: 16px; + line-height: 16px; + color: inherit; +} + +.ansible-sidebar-tree-action:hover { + background: var(--sidebar-active); +} + +.ansible-sidebar-tree-action:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 0; +} + +.ansible-sidebar-section-action .codicon, +.ansible-sidebar-tree-action .codicon, +.ansible-sidebar-tree-icon.codicon, +.ansible-sidebar-section-chevron.codicon, +.ansible-sidebar-tree-twistie.codicon { + font-family: codicon, sans-serif; +} diff --git a/packages/ui/src/sidebar/types.ts b/packages/ui/src/sidebar/types.ts new file mode 100644 index 0000000000..96b070a215 --- /dev/null +++ b/packages/ui/src/sidebar/types.ts @@ -0,0 +1,15 @@ +/** + * Re-export sidebar DTOs from @ansible/common (ADR-025). + * Kept for stable import paths from playground and UI code. + */ +export type { + SidebarSectionId, + SidebarNodeAction, + SidebarNodeExpand, + SidebarTreeNode, + SidebarSectionSeverity, + SidebarWelcomeAction, + SidebarSection, + SidebarSnapshot, + SidebarEnvManagerInput, +} from '@ansible/common'; diff --git a/packages/ui/src/views/SidebarNavTreeView.tsx b/packages/ui/src/views/SidebarNavTreeView.tsx new file mode 100644 index 0000000000..b11a987e8f --- /dev/null +++ b/packages/ui/src/views/SidebarNavTreeView.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from 'react'; +import type { SidebarNodeExpand, SidebarSectionId, SidebarSnapshot } from '../sidebar/types'; +import { SidebarShell } from '../sidebar/SidebarShell'; + +/** Minimal host messaging surface (VS Code webview API or Electron IPC wrapper). */ +export interface SidebarNavTreeHost { + postMessage(message: unknown): void; +} + +export interface SidebarNavTreeViewProps { + /** Initial snapshot from data-props (may be empty until host pushes). */ + initialSnapshot?: SidebarSnapshot; + /** + * Host message port. Must be the same object returned by the single + * acquireVsCodeApi() call in the webview entry (calling it twice throws). + */ + host?: SidebarNavTreeHost; +} + +/** + * Webview entry for the accordion sidebar NavTree. + * Host pushes snapshots; UI posts action command ids. + * @param root0 - Component props. + * @param root0.initialSnapshot - Initial snapshot from data-props (may be empty until host pushes). + * @param root0.host - Host message port (VS Code webview API or Electron IPC wrapper). + * @returns The rendered sidebar NavTree view. + */ +export function SidebarNavTreeView({ initialSnapshot, host }: SidebarNavTreeViewProps) { + const empty: SidebarSnapshot = { sections: [], suggestedOpenSectionId: null }; + const [snapshot, setSnapshot] = useState(initialSnapshot ?? empty); + const [openSectionId, setOpenSectionId] = useState( + initialSnapshot?.suggestedOpenSectionId ?? null, + ); + const readySent = useRef(false); + + useEffect(() => { + if (host && !readySent.current) { + readySent.current = true; + host.postMessage({ method: 'sidebar/ready' }); + } + + const onMessage = (event: MessageEvent) => { + const data: unknown = event.data; + if (typeof data !== 'object' || data === null) { + return; + } + const msg = data as { method?: unknown; params?: { snapshot?: SidebarSnapshot } }; + if (msg.method !== 'sidebar/setState' || !msg.params?.snapshot) { + return; + } + const next = msg.params.snapshot; + setSnapshot(next); + // Progressive updates send suggestedOpenSectionId: null — keep user selection. + // Only auto-open when the host has an issue-driven suggestion. + const suggested = next.suggestedOpenSectionId; + if (suggested) { + setOpenSectionId(suggested); + } + }; + window.addEventListener('message', onMessage); + return () => { + window.removeEventListener('message', onMessage); + }; + }, [host]); + + const postAction = (command: string, args?: unknown[]) => { + host?.postMessage({ + method: 'sidebar/action', + params: { command, args }, + }); + }; + + const postExpand = (nodeId: string, expand: SidebarNodeExpand) => { + host?.postMessage({ + method: 'sidebar/expandNode', + params: { nodeId, expand }, + }); + }; + + if (snapshot.sections.length === 0) { + return ( +
    +

    Loading Ansible…

    +
    + ); + } + + return ( + { + postAction(command, args); + }} + onNodeAction={(command, _nodeId, args) => { + postAction(command, args); + }} + onNodeExpand={postExpand} + /> + ); +} diff --git a/packages/ui/test/sidebar/SidebarShell.test.tsx b/packages/ui/test/sidebar/SidebarShell.test.tsx new file mode 100644 index 0000000000..6dbfd350ab --- /dev/null +++ b/packages/ui/test/sidebar/SidebarShell.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SidebarShell } from '../../src/sidebar/SidebarShell'; +import type { SidebarSnapshot } from '../../src/sidebar/types'; + +const snapshot: SidebarSnapshot = { + sections: [ + { + id: 'envManagers', + title: 'Environment Managers', + nodes: [{ id: 'env-1', label: 'venv', icon: 'vm' }], + }, + { + id: 'creator', + title: 'Creator', + nodes: [{ id: 'c-1', label: 'Init', icon: 'folder' }], + }, + { + id: 'playbooks', + title: 'Playbooks', + nodes: [{ id: 'p-1', label: 'site.yml', icon: 'file' }], + }, + ], + suggestedOpenSectionId: null, +}; + +describe('SidebarShell', () => { + it('marks the open section with is-open for flex fill layout', () => { + const html = renderToStaticMarkup( + undefined} + />, + ); + expect(html).toContain('ansible-sidebar-section is-open'); + expect(html).toContain('id="sidebar-panel-creator"'); + expect(html).toContain('Init'); + expect(html).not.toContain('id="sidebar-panel-playbooks"'); + }); + + it('fires onOpenSectionChange when a section header is present', () => { + const onOpen = vi.fn(); + const html = renderToStaticMarkup( + , + ); + // SSR cannot click; assert toggle buttons exist for accordion control. + expect(html).toContain('sidebar-section-creator'); + expect(html).toContain('aria-expanded="false"'); + expect(onOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/test/sidebar/SidebarTree.test.tsx b/packages/ui/test/sidebar/SidebarTree.test.tsx new file mode 100644 index 0000000000..862240ceb4 --- /dev/null +++ b/packages/ui/test/sidebar/SidebarTree.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SidebarTree } from '../../src/sidebar/SidebarTree'; +import type { SidebarTreeNode } from '../../src/sidebar/types'; + +describe('SidebarTree', () => { + it('renders labels and lazy-expandable rows', () => { + const nodes: SidebarTreeNode[] = [ + { + id: 'ee-1', + label: 'community-ansible-dev-tools:devel', + icon: 'package', + lazyChildren: true, + expand: { kind: 'eeDetail', fullName: 'ghcr.io/ansible/x:devel' }, + }, + { + id: 'leaf', + label: 'Leaf', + icon: 'file', + actions: [ + { + id: 'a1', + label: 'Run', + icon: 'play', + command: 'ansiblePlaybooks.run', + args: ['/tmp/p.yml'], + }, + ], + }, + ]; + + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('community-ansible-dev-tools:devel'); + expect(html).toContain('Leaf'); + expect(html).toContain('codicon-package'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc31adc9c4..c1d657be34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@vscode-elements/elements': specifier: ^1.6.0 version: 1.17.0 + '@vscode/codicons': + specifier: ^0.0.36 + version: 0.0.36 '@vscode/python-extension': specifier: ~1.0.5 version: 1.0.6 @@ -192,7 +195,7 @@ importers: dependencies: '@astrojs/starlight': specifier: ^0.41.1 - version: 0.41.1(@astrojs/markdown-remark@7.2.0)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@azure/identity@4.13.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.13.2)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(typescript@6.0.3) + version: 0.41.1(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@azure/identity@4.13.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.13.2)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(typescript@6.0.3) '@fontsource/red-hat-display': specifier: '*' version: 5.2.8 @@ -3145,6 +3148,9 @@ packages: peerDependencies: '@vscode/codicons': '>=0.0.40' + '@vscode/codicons@0.0.36': + resolution: {integrity: sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==} + '@vscode/codicons@0.0.45': resolution: {integrity: sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==} @@ -8356,14 +8362,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/markdown-satteri@0.3.2': + '@astrojs/markdown-satteri@0.3.2(@emnapi/core@1.11.1)': dependencies: '@astrojs/internal-helpers': 0.10.0 '@astrojs/prism': 4.0.2 github-slugger: 2.0.0 - satteri: 0.9.4 + satteri: 0.9.4(@emnapi/core@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' - '@astrojs/mdx@7.0.0(@astrojs/markdown-satteri@0.3.2)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@azure/identity@4.13.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.13.2)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))': + '@astrojs/mdx@7.0.0(@astrojs/markdown-satteri@0.3.2(@emnapi/core@1.11.1))(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@azure/identity@4.13.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.13.2)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.0 '@astrojs/markdown-remark': 7.2.0 @@ -8381,7 +8389,7 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 optionalDependencies: - '@astrojs/markdown-satteri': 0.3.2 + '@astrojs/markdown-satteri': 0.3.2(@emnapi/core@1.11.1) transitivePeerDependencies: - supports-color @@ -8395,10 +8403,10 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.4.3 - '@astrojs/starlight@0.41.1(@astrojs/markdown-remark@7.2.0)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@azure/identity@4.13.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.13.2)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(typescript@6.0.3)': + '@astrojs/starlight@0.41.1(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@azure/identity@4.13.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.13.2)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(typescript@6.0.3)': dependencies: - '@astrojs/markdown-satteri': 0.3.2 - '@astrojs/mdx': 7.0.0(@astrojs/markdown-satteri@0.3.2)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@azure/identity@4.13.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.13.2)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0)) + '@astrojs/markdown-satteri': 0.3.2(@emnapi/core@1.11.1) + '@astrojs/mdx': 7.0.0(@astrojs/markdown-satteri@0.3.2(@emnapi/core@1.11.1))(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@azure/identity@4.13.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.13.2)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@pagefind/default-ui': 1.5.2 '@types/hast': 3.0.4 @@ -8422,7 +8430,7 @@ snapshots: rehype: 13.0.2 rehype-format: 5.0.1 remark-directive: 4.0.0 - satteri: 0.9.4 + satteri: 0.9.4(@emnapi/core@1.11.1) ultrahtml: 1.6.0 unified: 11.0.5 unist-util-visit: 5.1.0 @@ -8430,6 +8438,7 @@ snapshots: optionalDependencies: '@astrojs/markdown-remark': 7.2.0 transitivePeerDependencies: + - '@emnapi/core' - supports-color - typescript @@ -8570,11 +8579,12 @@ snapshots: '@bruits/satteri-linux-x64-musl@0.9.4': optional: true - '@bruits/satteri-wasm32-wasi@0.9.4': + '@bruits/satteri-wasm32-wasi@0.9.4(@emnapi/core@1.11.1)': dependencies: - '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' optional: true '@bruits/satteri-win32-arm64-msvc@0.9.4': @@ -10812,6 +10822,8 @@ snapshots: '@vscode/codicons': 0.0.45 lit: 3.3.3 + '@vscode/codicons@0.0.36': {} + '@vscode/codicons@0.0.45': {} '@vscode/python-extension@1.0.6': {} @@ -11417,7 +11429,7 @@ snapshots: dependencies: '@astrojs/compiler-rs': 0.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@astrojs/internal-helpers': 0.10.0 - '@astrojs/markdown-satteri': 0.3.2 + '@astrojs/markdown-satteri': 0.3.2(@emnapi/core@1.11.1) '@astrojs/telemetry': 3.3.2 '@capsizecss/unpack': 4.0.1 '@clack/prompts': 1.6.0 @@ -15921,7 +15933,7 @@ snapshots: safer-buffer@2.1.2: {} - satteri@0.9.4: + satteri@0.9.4(@emnapi/core@1.11.1): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -15934,9 +15946,11 @@ snapshots: '@bruits/satteri-linux-arm64-musl': 0.9.4 '@bruits/satteri-linux-x64-gnu': 0.9.4 '@bruits/satteri-linux-x64-musl': 0.9.4 - '@bruits/satteri-wasm32-wasi': 0.9.4 + '@bruits/satteri-wasm32-wasi': 0.9.4(@emnapi/core@1.11.1) '@bruits/satteri-win32-arm64-msvc': 0.9.4 '@bruits/satteri-win32-x64-msvc': 0.9.4 + transitivePeerDependencies: + - '@emnapi/core' sax@1.6.0: {} diff --git a/src/extension.ts b/src/extension.ts index 57ae78972a..749494861c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,13 +8,13 @@ import { TransportKind, } from 'vscode-languageclient/node'; import { PluginDocPanel } from '@src/panels/PluginDocPanel'; -import { AnsibleDevToolsProvider } from '@src/views/AnsibleDevToolsProvider'; -import { EnvironmentManagersProvider } from '@src/views/EnvironmentManagersProvider'; -import { CollectionsProvider } from '@src/views/CollectionsProvider'; -import { ExecutionEnvironmentsProvider } from '@src/views/ExecutionEnvironmentsProvider'; -import { CreatorProvider } from '@src/views/CreatorProvider'; +import { AnsibleDevToolsController } from '@src/views/AnsibleDevToolsController'; +import { EnvironmentManagersController } from '@src/views/EnvironmentManagersController'; +import { CollectionsController } from '@src/views/CollectionsController'; +import { ExecutionEnvironmentsController } from '@src/views/ExecutionEnvironmentsController'; +import { CreatorController } from '@src/views/CreatorController'; import { CreatorFormPanel } from '@src/panels/CreatorFormPanel'; -import { PlaybooksProvider } from '@src/views/PlaybooksProvider'; +import { PlaybooksController } from '@src/views/PlaybooksController'; import { PlaybookConfigPanel } from '@src/panels/PlaybookConfigPanel'; import { PlaybookProgressPanel } from '@src/panels/PlaybookProgressPanel'; import { EEDetailPanel } from '@src/panels/EEDetailPanel'; @@ -23,16 +23,16 @@ import { PlaybooksService, PlaybookInfo, PlaybookPlay } from '@src/services/Play import { TerminalService } from '@src/services/TerminalService'; import { PythonEnvironmentService } from '@src/services/PythonEnvironmentService'; import { - McpToolsProvider, + McpToolsController, injectToolPromptIntoChat, type ToolInfo, -} from '@src/views/McpToolsProvider'; +} from '@src/views/McpToolsController'; import { openChatWithPrompt } from '@src/features/chatProvider'; import { - CollectionSourcesProvider, + CollectionSourcesController, setCollectionSourcesLogFunction, type CollectionSourceInfo, -} from '@src/views/CollectionSourcesProvider'; +} from '@src/views/CollectionSourcesController'; import { GalaxyCollectionCache, GalaxyDocsCache, @@ -42,6 +42,7 @@ import { DevToolsService, ExecutionEnvService, cacheSelectedEnvironment, + getCachedEnvironment, getCommandService, SkillRegistry, isExecutionEnvironmentDefinition, @@ -63,7 +64,7 @@ import type { SkillSource, SkillEntry, } from '@ansible/developer-services'; -import { SkillsProvider, openChatWithSkill, copySkillPrompt } from '@src/views/SkillsProvider'; +import { SkillsController, openChatWithSkill, copySkillPrompt } from '@src/views/SkillsController'; import { registerMcpServerProvider, isMcpAvailable, @@ -87,6 +88,7 @@ import { DiagnosticsPanel } from '@src/panels/DiagnosticsPanel'; import { TelemetryService } from '@src/services/TelemetryService'; import { emitJourneyOutcome } from '@src/services/journeyTelemetry'; import { TelemetryEvents } from '@ansible/common'; +import { AnsibleNavTreeProvider } from '@src/sidebar/AnsibleNavTreeProvider'; // Create output channel for extension logs export const outputChannel = vscode.window.createOutputChannel('Ansible'); @@ -237,53 +239,52 @@ export async function activate(context: vscode.ExtensionContext) { }), ); - // Check if workspace is open - if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) { - vscode.window.showWarningMessage('Ansible requires an open workspace folder.'); - return; - } - - // Start the Ansible Language Server - const serverModule = context.asAbsolutePath(path.join('dist', 'language-server.js')); - const workspaceRoot = vscode.workspace.workspaceFolders[0].uri.fsPath; - const serverEnv: Record = {}; - if (workspaceRoot) { - serverEnv.ANSIBLE_ENV_WORKSPACE = workspaceRoot; - } - const serverOptions: ServerOptions = { - run: { - module: serverModule, - transport: TransportKind.ipc, - options: { cwd: workspaceRoot, env: serverEnv }, - }, - debug: { - module: serverModule, - transport: TransportKind.ipc, - options: { - execArgv: ['--nolazy', '--inspect=6009'], - cwd: workspaceRoot, - env: serverEnv, + let languageClient: LanguageClient | undefined; + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + void vscode.window.showWarningMessage('Ansible requires an open workspace folder.'); + } else { + const serverModule = context.asAbsolutePath(path.join('dist', 'language-server.js')); + const workspaceRoot = workspaceFolder.uri.fsPath; + const serverEnv: Record = {}; + if (workspaceRoot) { + serverEnv.ANSIBLE_ENV_WORKSPACE = workspaceRoot; + } + const serverOptions: ServerOptions = { + run: { + module: serverModule, + transport: TransportKind.ipc, + options: { cwd: workspaceRoot, env: serverEnv }, }, - }, - }; - const clientOptions: LanguageClientOptions = { - documentSelector: [{ scheme: 'file', language: 'ansible' }], - synchronize: { - fileEvents: [ - vscode.workspace.createFileSystemWatcher('**/ansible.cfg'), - vscode.workspace.createFileSystemWatcher('**/.ansible-lint'), - ], - }, - }; - const languageClient = new LanguageClient( - 'ansibleLanguageServer', - 'Ansible Language Server', - serverOptions, - clientOptions, - ); - void languageClient.start(); - context.subscriptions.push(languageClient); - log('Ansible Language Server started'); + debug: { + module: serverModule, + transport: TransportKind.ipc, + options: { + execArgv: ['--nolazy', '--inspect=6009'], + cwd: workspaceRoot, + env: serverEnv, + }, + }, + }; + const clientOptions: LanguageClientOptions = { + documentSelector: [{ scheme: 'file', language: 'ansible' }], + synchronize: { + fileEvents: [ + vscode.workspace.createFileSystemWatcher('**/ansible.cfg'), + vscode.workspace.createFileSystemWatcher('**/.ansible-lint'), + ], + }, + }; + languageClient = new LanguageClient( + 'ansibleLanguageServer', + 'Ansible Language Server', + serverOptions, + clientOptions, + ); + void languageClient.start(); + context.subscriptions.push(languageClient); + log('Ansible Language Server started'); + } // Initialize centralized Python environment service (handles PET detection, // ms-python.python fallback, and environment change events) @@ -374,7 +375,21 @@ export async function activate(context: vscode.ExtensionContext) { const devToolsService = DevToolsService.getInstance(); devToolsService.setPackageInstaller(async () => { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri; - const env = await pythonEnvService.getEnvironment(workspaceFolder); + let env = await pythonEnvService.getEnvironment(workspaceFolder); + // After NavTree/native select, getEnvironment can lag — resolve from + // the env we just cached (cacheSelectedEnvironment on select). + if (!env) { + const cached = getCachedEnvironment(); + if (cached?.pythonPath) { + const all = await pythonEnvService.getEnvironments('all'); + env = all.find((e) => e.execInfo.run.executable === cached.pythonPath); + if (env) { + log( + `packageInstaller: resolved env from cache (${env.displayName})`, + ); + } + } + } if (!env) { throw new Error('No Python environment selected'); } @@ -443,78 +458,43 @@ export async function activate(context: vscode.ExtensionContext) { log(`Python Environment Service initialization failed: ${formatError(error)}`); }); - // Register the Environment Managers view - const envManagersProvider = new EnvironmentManagersProvider(pythonEnvService); - const envManagersView = vscode.window.createTreeView('ansibleDevToolsEnvManagers', { - treeDataProvider: envManagersProvider, - showCollapseAll: true, - }); - context.subscriptions.push(envManagersView); - - // Register the Packages view - const devToolsProvider = new AnsibleDevToolsProvider(pythonEnvService); - const packagesView = vscode.window.createTreeView('ansibleDevToolsPackages', { - treeDataProvider: devToolsProvider, - showCollapseAll: false, - }); - context.subscriptions.push(packagesView); - - // Register the Collections view - const collectionsProvider = new CollectionsProvider(pythonEnvService); - const collectionsView = vscode.window.createTreeView('ansibleDevToolsCollections', { - treeDataProvider: collectionsProvider, - showCollapseAll: true, - }); - context.subscriptions.push(collectionsView); - - // Register the Execution Environments view - const eeProvider = new ExecutionEnvironmentsProvider(); - const eeView = vscode.window.createTreeView('ansibleExecutionEnvironments', { - treeDataProvider: eeProvider, - showCollapseAll: true, - }); - context.subscriptions.push(eeView); + // Controllers for NavTree hydrate + command handlers (no TreeViews — ADR-025 NavTree-only) + const envManagersController = new EnvironmentManagersController(pythonEnvService); - // Register the Creator view - const creatorProvider = new CreatorProvider(); - const creatorView = vscode.window.createTreeView('ansibleCreator', { - treeDataProvider: creatorProvider, - showCollapseAll: true, - }); - context.subscriptions.push(creatorView); + const navTreeProvider = new AnsibleNavTreeProvider( + context, + envManagersController, + pythonEnvService, + ); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + AnsibleNavTreeProvider.viewType, + navTreeProvider, + { webviewOptions: { retainContextWhenHidden: true } }, + ), + vscode.commands.registerCommand('ansible.sidebar.navTree.open', async () => { + await vscode.commands.executeCommand('ansibleNavTree.focus'); + }), + ); - // Register the Playbooks view - const playbooksProvider = new PlaybooksProvider(); - const playbooksView = vscode.window.createTreeView('ansiblePlaybooks', { - treeDataProvider: playbooksProvider, - showCollapseAll: true, - }); - context.subscriptions.push(playbooksView); + const devToolsController = new AnsibleDevToolsController(pythonEnvService); + const collectionsController = new CollectionsController(pythonEnvService); + const eeController = new ExecutionEnvironmentsController(); + const creatorController = new CreatorController(); + const playbooksController = new PlaybooksController(); - // Refresh playbooks when workspace folders change const workspaceFoldersListener = vscode.workspace.onDidChangeWorkspaceFolders(() => { log('Workspace folders changed, refreshing playbooks...'); - playbooksProvider.refresh(); + playbooksController.refresh(); }); context.subscriptions.push(workspaceFoldersListener); - // Register the MCP Tools view - const mcpToolsProvider = new McpToolsProvider(context); - const mcpToolsView = vscode.window.createTreeView('ansibleMcpTools', { - treeDataProvider: mcpToolsProvider, - showCollapseAll: true, - }); - context.subscriptions.push(mcpToolsView); + const mcpToolsController = new McpToolsController(context); + navTreeProvider.setMcpToolsController(mcpToolsController); - // Register the Collection Sources view - const collectionSourcesProvider = new CollectionSourcesProvider(); - const collectionSourcesView = vscode.window.createTreeView('ansibleCollectionSources', { - treeDataProvider: collectionSourcesProvider, - showCollapseAll: true, - }); - context.subscriptions.push(collectionSourcesView); + const collectionSourcesController = new CollectionSourcesController(); + navTreeProvider.setCollectionSourcesController(collectionSourcesController); - // Configure skill registry from settings and register Skills view const skillSources = vscode.workspace .getConfiguration('ansibleEnvironments') @@ -522,16 +502,24 @@ export async function activate(context: vscode.ExtensionContext) { const skillRegistry = SkillRegistry.getInstance(); skillRegistry.setSources(skillSources); - const skillsProvider = new SkillsProvider(); - const skillsView = vscode.window.createTreeView('ansibleSkills', { - treeDataProvider: skillsProvider, - showCollapseAll: true, - }); - context.subscriptions.push(skillsView); + const skillsController = new SkillsController(); + + context.subscriptions.push( + envManagersController, + navTreeProvider, + devToolsController, + collectionsController, + eeController, + creatorController, + playbooksController, + mcpToolsController, + collectionSourcesController, + skillsController, + ); context.subscriptions.push( vscode.commands.registerCommand('ansibleSkills.refresh', () => { - skillsProvider.refresh(); + skillsController.refresh(); }), vscode.commands.registerCommand( 'ansibleSkills.useInChat', @@ -588,7 +576,7 @@ export async function activate(context: vscode.ExtensionContext) { .getConfiguration('ansibleEnvironments') .get('skillSources') ?? []; skillRegistry.setSources(updated); - skillsProvider.refresh(); + skillsController.refresh(); } }), ); @@ -597,7 +585,7 @@ export async function activate(context: vscode.ExtensionContext) { const refreshCommand = vscode.commands.registerCommand( 'ansibleDevToolsPackages.refresh', () => { - void devToolsProvider.refresh(); + void devToolsController.refresh(); }, ); @@ -606,8 +594,29 @@ export async function activate(context: vscode.ExtensionContext) { async () => { try { const devToolsService = DevToolsService.getInstance(); - await devToolsService.install(); - vscode.window.showInformationMessage('ansible-dev-tools installation started.'); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Installing ansible-dev-tools…', + cancellable: false, + }, + async () => { + await devToolsService.install(); + }, + ); + if (devToolsService.hasPackages()) { + vscode.window.showInformationMessage( + 'ansible-dev-tools installed in the selected environment.', + ); + } else { + vscode.window.showWarningMessage( + 'ansible-dev-tools install finished, but packages were not detected yet. Try Refresh on Ansible Dev Tools.', + ); + } + void devToolsController.refresh(); + // Re-index collections now that ansible-doc / ade exist in the env + // (ansible.builtin and site-packages collections). + void collectionsController.forceRefresh(); } catch (error) { vscode.window.showErrorMessage( `Failed to install ansible-dev-tools: ${formatError(error)}`, @@ -623,6 +632,7 @@ export async function activate(context: vscode.ExtensionContext) { const devToolsService = DevToolsService.getInstance(); await devToolsService.upgrade(); vscode.window.showInformationMessage('Upgrading ansible-dev-tools...'); + void collectionsController.forceRefresh(); } catch (error) { vscode.window.showErrorMessage( `Failed to upgrade ansible-dev-tools: ${formatError(error)}`, @@ -635,7 +645,7 @@ export async function activate(context: vscode.ExtensionContext) { const envManagersRefreshCommand = vscode.commands.registerCommand( 'ansibleDevToolsEnvManagers.refresh', () => { - void envManagersProvider.refresh(); + void envManagersController.refresh(); }, ); @@ -654,17 +664,24 @@ export async function activate(context: vscode.ExtensionContext) { return; } + // Ansible-owned create: seed ansible-dev-tools so the new + // venv is immediately usable (lint, creator, navigator, etc.). const environment = await pythonEnvService.createEnvironment(workspaceFolder, { quickCreate: false, + additionalPackages: ['ansible-dev-tools'], }); if (environment) { + const execPath = environment.execInfo.run.executable; + if (execPath) { + cacheSelectedEnvironment(execPath, environment.displayName); + } vscode.window.showInformationMessage( `Created environment: ${environment.displayName}`, ); - void envManagersProvider.refresh(); - void devToolsProvider.refresh(); - void collectionsProvider.refresh(); + void envManagersController.refresh(); + void devToolsController.refresh(); + void collectionsController.forceRefresh(); emitJourneyOutcome(TelemetryEvents.ENV_CREATE, 'success', { startedAt }); } else { emitJourneyOutcome(TelemetryEvents.ENV_CREATE, 'cancel', { startedAt }); @@ -706,13 +723,23 @@ export async function activate(context: vscode.ExtensionContext) { } await pythonEnvService.setEnvironment(workspaceFolder, environment); + + // Cache immediately so CommandService/DevTools resolve the new + // venv bin even if getEnvironment lags behind the API switch. + const execPath = environment.execInfo.run.executable; + if (execPath) { + cacheSelectedEnvironment(execPath, environment.displayName); + } + vscode.window.showInformationMessage( `Selected environment: ${environment.displayName}`, ); - void envManagersProvider.refresh(); - void devToolsProvider.refresh(); - void collectionsProvider.refresh(); + void envManagersController.refresh(); + void devToolsController.refresh(); + // Force re-index — soft refresh may reuse an empty cache from + // before ansible-doc was available in this env. + void collectionsController.forceRefresh(); } catch (error) { vscode.window.showErrorMessage( `Failed to select environment: ${formatError(error)}`, @@ -738,7 +765,7 @@ export async function activate(context: vscode.ExtensionContext) { const collectionsRefreshCommand = vscode.commands.registerCommand( 'ansibleDevToolsCollections.refresh', () => { - void collectionsProvider.refresh(); + void collectionsController.forceRefresh(); }, ); @@ -821,7 +848,7 @@ export async function activate(context: vscode.ExtensionContext) { const eeRefreshCommand = vscode.commands.registerCommand( 'ansibleExecutionEnvironments.refresh', () => { - eeProvider.refresh(); + eeController.refresh(); }, ); @@ -1034,7 +1061,7 @@ export async function activate(context: vscode.ExtensionContext) { // Register Creator commands const creatorRefreshCommand = vscode.commands.registerCommand('ansibleCreator.refresh', () => { - creatorProvider.refresh(); + creatorController.refresh(); }); const creatorOpenFormCommand = vscode.commands.registerCommand( @@ -1060,7 +1087,7 @@ export async function activate(context: vscode.ExtensionContext) { const playbooksRefreshCommand = vscode.commands.registerCommand( 'ansiblePlaybooks.refresh', () => { - playbooksProvider.refresh(); + playbooksController.refresh(); }, ); @@ -1215,7 +1242,7 @@ export async function activate(context: vscode.ExtensionContext) { const mcpToolsRefreshCommand = vscode.commands.registerCommand( 'ansibleMcpTools.refresh', () => { - mcpToolsProvider.refresh(); + mcpToolsController.refresh(); }, ); @@ -1268,7 +1295,7 @@ export async function activate(context: vscode.ExtensionContext) { await configureCursorMcp(context); } updateMcpStatusContext(); - mcpToolsProvider.refresh(); + mcpToolsController.refresh(); }, ); @@ -1276,77 +1303,77 @@ export async function activate(context: vscode.ExtensionContext) { const collectionSourcesRefreshCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.refresh', async () => { - await collectionSourcesProvider.refreshAll(); + await collectionSourcesController.refreshAll(); }, ); const collectionSourcesRefreshSourceCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.refreshSource', async (node: { source: CollectionSourceInfo }) => { - await collectionSourcesProvider.refreshSource(node.source); + await collectionSourcesController.refreshSource(node.source); }, ); const collectionSourcesAddSourceCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.addSource', async () => { - await collectionSourcesProvider.addSource(); + await collectionSourcesController.addSource(); }, ); const collectionSourcesInstallCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.install', async () => { - await collectionSourcesProvider.installCollection(); + await collectionSourcesController.installCollection(); }, ); const collectionSourcesSearchCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.search', async () => { - await collectionSourcesProvider.searchAllSources(); + await collectionSourcesController.searchAllSources(); }, ); const collectionSourcesSearchSourceCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.searchSource', async (node: { source: CollectionSourceInfo }) => { - await collectionSourcesProvider.searchSource(node.source); + await collectionSourcesController.searchSource(node.source); }, ); const collectionSourcesInstallFromSourceCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.installFromSource', async (node: { source: CollectionSourceInfo }) => { - await collectionSourcesProvider.installFromSource(node.source); + await collectionSourcesController.installFromSource(node.source); }, ); const collectionSourcesAiSummaryCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.aiSummary', () => { - void collectionSourcesProvider.generateAiSummary(); + void collectionSourcesController.generateAiSummary(); }, ); const collectionSourcesAiSourceSummaryCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.aiSourceSummary', (node: { source: CollectionSourceInfo }) => { - void collectionSourcesProvider.generateSourceAiSummary(node.source); + void collectionSourcesController.generateSourceAiSummary(node.source); }, ); const filterGalaxyCollectionsCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.filterGalaxyCollections', async () => { - await collectionSourcesProvider.filterGalaxyCollections(); + await collectionSourcesController.filterGalaxyCollections(); }, ); const clearGalaxyFilterCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.clearGalaxyFilter', () => { - collectionSourcesProvider.clearGalaxyFilter(); + collectionSourcesController.clearGalaxyFilter(); }, ); @@ -1357,20 +1384,18 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.showWarningMessage('Select a Galaxy collection from the tree view.'); return; } - await collectionSourcesProvider.installGalaxyCollection( - node as Parameters[0], - ); + await collectionSourcesController.installGalaxyCollection(node); }, ); const showGalaxyPluginDocCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.showGalaxyPluginDoc', - async (node?: Parameters[0]) => { + async (node?: Parameters[0]) => { if (!node) { vscode.window.showWarningMessage('Select a Galaxy plugin from the tree view.'); return; } - await collectionSourcesProvider.showGalaxyPluginDoc(node, context.extensionUri); + await collectionSourcesController.showGalaxyPluginDoc(node, context.extensionUri); }, ); @@ -1435,23 +1460,23 @@ export async function activate(context: vscode.ExtensionContext) { const showGitHubPluginDocCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.showGitHubPluginDoc', - async (node?: Parameters[0]) => { + async (node?: Parameters[0]) => { if (!node) { vscode.window.showWarningMessage('Select a GitHub plugin from the tree view.'); return; } - await collectionSourcesProvider.showGitHubPluginDoc(node, context.extensionUri); + await collectionSourcesController.showGitHubPluginDoc(node, context.extensionUri); }, ); const refreshGitHubCollectionCommand = vscode.commands.registerCommand( 'ansibleCollectionSources.refreshGitHubCollection', - (node?: Parameters[0]) => { + (node?: Parameters[0]) => { if (!node) { vscode.window.showWarningMessage('Select a GitHub collection from the tree view.'); return; } - collectionSourcesProvider.refreshGitHubCollection(node); + collectionSourcesController.refreshGitHubCollection(node); }, ); @@ -1567,7 +1592,7 @@ export async function activate(context: vscode.ExtensionContext) { log(`Collection install output: ${output}`); // Refresh the collections view - void collectionsProvider.refresh(); + void collectionsController.refresh(); emitJourneyOutcome(TelemetryEvents.COLLECTION_INSTALL, 'success', { startedAt, }); diff --git a/src/panels/webview-entry.tsx b/src/panels/webview-entry.tsx index d796742996..9a089c0884 100644 --- a/src/panels/webview-entry.tsx +++ b/src/panels/webview-entry.tsx @@ -17,15 +17,18 @@ import { PlaybookConfigView, PlaybookProgressView, DiagnosticsView, + SidebarNavTreeView, } from '@ansible/ui'; -import type { SchemaNode } from '@ansible/ui'; +import type { SchemaNode, SidebarSnapshot } from '@ansible/ui'; import { buildPreviewString } from '@ansible/common'; import { VsCodeBridge } from './bridges/VsCodeBridge'; // esbuild imports CSS as text via loader config; inject at runtime import tokensCss from '@ansible/ui/styles/tokens.css'; +import vscodeHostCss from '@ansible/ui/styles/vscode-host.css'; +import sidebarCss from '@ansible/ui/sidebar/sidebar.css'; const style = document.createElement('style'); -style.textContent = tokensCss; +style.textContent = `${tokensCss}\n${vscodeHostCss}\n${sidebarCss}`; document.head.appendChild(style); const vscode = acquireVsCodeApi(); @@ -100,6 +103,13 @@ function App() { return ; case 'diagnostics': return ; + case 'sidebar-navtree': + return ( + + ); default: return
    Unknown view: {viewName}
    ; } diff --git a/src/services/PythonEnvironmentService.ts b/src/services/PythonEnvironmentService.ts index d79418d821..a0faa60651 100644 --- a/src/services/PythonEnvironmentService.ts +++ b/src/services/PythonEnvironmentService.ts @@ -612,18 +612,22 @@ export class PythonEnvironmentService implements vscode.Disposable { return undefined; } - return this._createEnvironmentViaTerminal(scope); + return this._createEnvironmentViaTerminal(scope, options); } /** * Terminal fallback for venv creation when python-envs is not installed. * Resolves the active interpreter from ms-python.python and uses its * exact path (avoids `python` vs `python3` portability issues on macOS). + * When {@link CreateEnvironmentOptions.additionalPackages} is set, installs + * those packages into the new venv with `python -m pip` after creation. * @param scope - workspace URI(s) or 'global' indicating where to create the venv + * @param options - Optional create options (additional packages) * @returns the newly created Python environment, or undefined if cancelled/failed */ private async _createEnvironmentViaTerminal( scope: vscode.Uri | vscode.Uri[] | 'global', + options?: CreateEnvironmentOptions, ): Promise { const workspaceUri = Array.isArray(scope) ? scope[0] @@ -722,6 +726,45 @@ export class PythonEnvironmentService implements vscode.Disposable { return undefined; } + const extraPackages = options?.additionalPackages?.filter(Boolean) ?? []; + if (extraPackages.length > 0) { + const pkgList = extraPackages.map((p) => `'${p.replace(/'/g, "'\\''")}'`).join(' '); + const winPkgList = extraPackages.map((p) => `"${p.replace(/"/g, '""')}"`).join(' '); + const pipCmd = + process.platform === 'win32' + ? `"${pythonBin.replace(/"/g, '""')}" -m pip install ${winPkgList}` + : `'${pythonBin.replace(/'/g, "'\\''")}' -m pip install ${pkgList}`; + log(`Installing packages into new venv: ${pipCmd}`); + await new Promise((resolve) => { + const shellIntegration = ( + terminal as { + shellIntegration?: { + onDidEndCommandExecution?: ( + cb: (e: { exitCode: number | undefined }) => void, + ) => { dispose(): void }; + }; + } + ).shellIntegration; + const onEnd = shellIntegration?.onDidEndCommandExecution; + if (onEnd) { + const listener = onEnd((e) => { + listener.dispose(); + if (e.exitCode !== 0) { + vscode.window.showWarningMessage( + `Virtual environment created, but package install exited with code ${String(e.exitCode ?? 'unknown')}. Use Install ansible-dev-tools to retry.`, + ); + } + resolve(); + }); + terminal.sendText(pipCmd); + } else { + terminal.sendText(pipCmd); + // Best-effort wait when shell integration is unavailable + setTimeout(resolve, 60_000); + } + }); + } + log(`Venv created, selecting: ${pythonBin}`); if (pyApi) { try { diff --git a/src/services/TerminalService.ts b/src/services/TerminalService.ts index 35283022ac..897d1dd1b4 100644 --- a/src/services/TerminalService.ts +++ b/src/services/TerminalService.ts @@ -6,6 +6,8 @@ */ import * as vscode from 'vscode'; +import { getCachedEnvironment } from '@ansible/developer-services'; +import type { PythonEnvironment } from '@ansible/developer-services'; import type { PythonEnvironmentService } from '@src/services/PythonEnvironmentService'; export interface CommandResult { @@ -78,7 +80,7 @@ export class TerminalService { let expectActivation = false; if (this._pythonEnvService?.hasEnvsExtension() && workspaceFolder) { - const environment = await this._pythonEnvService.getEnvironment(workspaceFolder); + const environment = await this._resolveActiveEnvironment(workspaceFolder); if (environment) { terminal = await this._pythonEnvService.createTerminal(environment, { @@ -138,6 +140,30 @@ export class TerminalService { return { terminal, sendCommand, dispose }; } + /** + * Resolve the active Python environment, falling back to the env cache + * when getEnvironment lags behind a recent selection. + * @param workspaceFolder - Workspace URI for API lookup + * @returns Active or cache-matched environment, if any + */ + private async _resolveActiveEnvironment( + workspaceFolder: vscode.Uri, + ): Promise { + if (!this._pythonEnvService) { + return undefined; + } + const fromApi = await this._pythonEnvService.getEnvironment(workspaceFolder); + if (fromApi) { + return fromApi; + } + const cached = getCachedEnvironment(); + if (!cached?.pythonPath) { + return undefined; + } + const all = await this._pythonEnvService.getEnvironments('all'); + return all.find((e) => e.execInfo.run.executable === cached.pythonPath); + } + /** * Wait until Python environment activation completes for a terminal. * @param terminal - Terminal whose activation state should be observed diff --git a/src/sidebar/AnsibleNavTreeProvider.ts b/src/sidebar/AnsibleNavTreeProvider.ts new file mode 100644 index 0000000000..9280905f6e --- /dev/null +++ b/src/sidebar/AnsibleNavTreeProvider.ts @@ -0,0 +1,616 @@ +import * as vscode from 'vscode'; +import * as crypto from 'crypto'; +import { + CollectionsService, + CreatorService, + DevToolsService, + ExecutionEnvService, + GalaxyCollectionCache, + GalaxyDocsCache, + GitHubCollectionCache, + SCMDocsCache, + SidebarModel, + SkillRegistry, + assembleSidebarInput, + discoverPlaybooks, + preserveExpandedChildren, + type SidebarSnapshot, + type SidebarTreeNode, +} from '@ansible/developer-services'; +import { log, type SidebarNodeExpand } from '@ansible/common'; +import type { EnvironmentManagersController } from '@src/views/EnvironmentManagersController'; +import type { PythonEnvironmentService } from '@src/services/PythonEnvironmentService'; +import type { McpToolsController } from '@src/views/McpToolsController'; +import type { CollectionSourcesController } from '@src/views/CollectionSourcesController'; +import { getMcpStatus } from '@src/mcp/cursorConfig'; +import { + extractSelectEnvironmentId, + isPlaybookCommand, + normalizePlaybookPayload, +} from '@src/sidebar/navTreeCommandArgs'; +import { buildSidebarNavTreeHtml } from '@src/sidebar/navTreeHtml'; +import { MCP_CATEGORY_LABELS, mcpIdeDisplayName } from '@src/sidebar/mcpLabels'; + +/** + * WebviewView host for the accordion sidebar NavTree (ADR-025). + * Sole Activity Bar UI — snapshot hydrate, command dispatch, lazy expand. + */ +export class AnsibleNavTreeProvider implements vscode.WebviewViewProvider, vscode.Disposable { + public static readonly viewType = 'ansibleNavTree'; + + private _view?: vscode.WebviewView; + private readonly _model = new SidebarModel(); + private _viewDisposables: vscode.Disposable[] = []; + private _controllerDisposables: vscode.Disposable[] = []; + private _mcpTools?: McpToolsController; + private _collectionSources?: CollectionSourcesController; + /** Bumps on each progressive push so a stale hydrate cannot overwrite a newer one. */ + private _snapshotGeneration = 0; + /** After the first full hydrate, skip skeleton flashes on incremental refreshes. */ + private _hasHydrated = false; + /** Last posted snapshot (for lazy-expand patches). */ + private _lastSnapshot?: SidebarSnapshot; + /** Coalesce bursts of controller/config change events into one rebuild. */ + private static readonly _SNAPSHOT_DEBOUNCE_MS = 200; + private _snapshotDebounce?: ReturnType; + + /** + * @param _context - Extension context (MCP status, webview resources) + * @param _envManagers - Native env managers controller (snapshot source) + * @param _pythonEnvService - Python availability / capability + */ + constructor( + private readonly _context: vscode.ExtensionContext, + private readonly _envManagers: EnvironmentManagersController, + private readonly _pythonEnvService: PythonEnvironmentService, + ) {} + + /** + * Extension install URI (webview resource root). + * @returns Extension root URI from the activation context + */ + private get _extensionUri(): vscode.Uri { + return this._context.extensionUri; + } + + /** + * Attach MCP tools controller after it is constructed in activate(). + * @param controller - Native AI Tools controller + */ + setMcpToolsController(controller: McpToolsController): void { + this._mcpTools = controller; + this._controllerDisposables.push( + controller.onDidChange(() => { + this._schedulePushSnapshot(); + }), + ); + this._schedulePushSnapshot(); + } + + /** + * Attach Collection Sources controller (Galaxy filter state + refresh events). + * @param controller - Native collection sources controller + */ + setCollectionSourcesController(controller: CollectionSourcesController): void { + this._collectionSources = controller; + this._controllerDisposables.push( + controller.onDidChange(() => { + this._schedulePushSnapshot(); + }), + ); + this._schedulePushSnapshot(); + } + + /** + * Called when the NavTree webview becomes visible. + * @param webviewView - VS Code webview view instance + */ + resolveWebviewView(webviewView: vscode.WebviewView): void { + this._view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this._extensionUri, 'dist'), + vscode.Uri.joinPath(this._extensionUri, 'node_modules', '@vscode', 'codicons'), + ], + }; + + webviewView.webview.html = this._getHtml(webviewView.webview); + + this._viewDisposables.push( + webviewView.webview.onDidReceiveMessage( + (msg: { method?: string; params?: unknown }) => { + void this._handleMessage(msg); + }, + ), + ); + + this._viewDisposables.push( + this._envManagers.onDidChange(() => { + this._schedulePushSnapshot(); + }), + ); + + const devTools = DevToolsService.getInstance(); + const collections = CollectionsService.getInstance(); + const creator = CreatorService.getInstance(); + const ees = ExecutionEnvService.getInstance(); + this._viewDisposables.push( + (devTools.onDidChange as vscode.Event)(() => { + this._schedulePushSnapshot(); + }), + (collections.onDidChange as vscode.Event)(() => { + this._schedulePushSnapshot(); + }), + (creator.onDidChange as vscode.Event)(() => { + this._schedulePushSnapshot(); + }), + (ees.onDidChange as vscode.Event)(() => { + this._schedulePushSnapshot(); + }), + vscode.workspace.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration('ansibleEnvironments.enableAiFeatures') || + e.affectsConfiguration('ansible.lightspeed.enabled') || + e.affectsConfiguration('ansibleEnvironments.skillSources') + ) { + this._schedulePushSnapshot(); + } + }), + vscode.authentication.onDidChangeSessions((e) => { + if (e.provider.id === 'auth-lightspeed') { + this._schedulePushSnapshot(); + } + }), + ); + + webviewView.onDidDispose(() => { + this._clearSnapshotDebounce(); + for (const d of this._viewDisposables) { + d.dispose(); + } + this._viewDisposables = []; + this._view = undefined; + this._hasHydrated = false; + this._lastSnapshot = undefined; + }); + + // First visible hydrate is immediate (progressive skeleton); event bursts debounce. + void this._pushSnapshot({ progressive: true }); + } + + /** Release view and controller subscriptions on extension deactivate. */ + dispose(): void { + this._clearSnapshotDebounce(); + for (const d of this._viewDisposables) { + d.dispose(); + } + this._viewDisposables = []; + for (const d of this._controllerDisposables) { + d.dispose(); + } + this._controllerDisposables = []; + } + + /** + * Coalesce rapid change notifications into a single snapshot rebuild. + * Progressive / ready paths call `_pushSnapshot` directly instead. + */ + private _schedulePushSnapshot(): void { + if (this._snapshotDebounce) { + clearTimeout(this._snapshotDebounce); + } + this._snapshotDebounce = setTimeout(() => { + this._snapshotDebounce = undefined; + void this._pushSnapshot(); + }, AnsibleNavTreeProvider._SNAPSHOT_DEBOUNCE_MS); + } + + /** Cancel a pending debounced snapshot push. */ + private _clearSnapshotDebounce(): void { + if (this._snapshotDebounce) { + clearTimeout(this._snapshotDebounce); + this._snapshotDebounce = undefined; + } + } + + /** + * Handle ready / action / expand messages from the React shell. + * @param msg - postMessage payload from the webview + * @param msg.method - Message method name (e.g. sidebar/ready, sidebar/action) + * @param msg.params - Optional method-specific parameters + */ + private async _handleMessage(msg: { method?: string; params?: unknown }): Promise { + if (msg.method === 'sidebar/ready') { + // First open: headers immediately; later ready (rare) refreshes in place. + void this._pushSnapshot({ progressive: !this._hasHydrated }); + return; + } + if (msg.method === 'sidebar/expandNode') { + const params = msg.params as + { nodeId?: string; expand?: SidebarNodeExpand } | undefined; + if (params?.nodeId && params.expand) { + await this._expandNode(params.nodeId, params.expand); + } + return; + } + if (msg.method === 'sidebar/action') { + const params = msg.params as { command?: string; args?: unknown[] } | undefined; + const command = params?.command; + if (!command) { + return; + } + try { + const rawArgs = params.args ?? []; + log(`AnsibleNavTree: action ${command}`); + if (command === 'workbench.extensions.search') { + const query = typeof rawArgs[0] === 'string' ? rawArgs[0] : 'ms-python.python'; + await vscode.commands.executeCommand('workbench.extensions.search', query); + return; + } + const args = await this._enrichCommandArgs(command, rawArgs); + if (args === null) { + return; + } + await vscode.commands.executeCommand(command, ...args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage(`Sidebar action failed: ${message}`); + } + } + } + + /** + * Lazy-load Galaxy/GitHub collection → plugin-type → plugin children (native parity). + * @param nodeId - Tree node id to patch + * @param expand - Collection identity for docs caches + */ + private async _expandNode(nodeId: string, expand: SidebarNodeExpand): Promise { + if (!this._lastSnapshot) { + return; + } + + let children: SidebarTreeNode[]; + try { + const enableAi = + vscode.workspace + .getConfiguration('ansibleEnvironments') + .get('enableAiFeatures') ?? true; + + if (expand.kind === 'eeDetail') { + const details = await ExecutionEnvService.getInstance().loadDetails( + expand.fullName, + ); + const systemPackages = details + ? await ExecutionEnvService.getInstance().getSystemPackages(expand.fullName) + : []; + children = this._model.buildEeDetailNodes( + nodeId, + expand.fullName, + details, + systemPackages, + ); + } else if (expand.kind === 'galaxyCollection') { + const pluginTypes = await GalaxyDocsCache.getInstance().getPluginTypes( + expand.namespace, + expand.name, + expand.version, + ); + children = this._model.buildPluginTypeNodes( + nodeId, + pluginTypes, + { + namespace: expand.namespace, + name: expand.name, + version: expand.version, + }, + 'galaxy', + enableAi, + ); + } else { + const repo = expand.repository.split('/').pop() ?? expand.repository; + const pluginTypes = await SCMDocsCache.getInstance().getPluginTypes( + expand.org, + repo, + expand.namespace, + expand.name, + ); + children = this._model.buildPluginTypeNodes( + nodeId, + pluginTypes, + { + namespace: expand.namespace, + name: expand.name, + version: expand.version, + org: expand.org, + repository: expand.repository, + }, + 'github', + enableAi, + ); + } + } catch { + children = [ + { + id: `${nodeId}-error`, + label: 'Failed to load details', + icon: 'warning', + }, + ]; + } + + const next = this._model.patchNodeChildren(this._lastSnapshot, nodeId, children); + this._postSnapshot(next); + } + + /** + * Attach non-serializable fields (Uri / PythonEnvironment) expected by native commands. + * @param command - VS Code command id + * @param args - Args from the webview DTO + * @returns Args for executeCommand, or null when the action was cancelled + */ + private async _enrichCommandArgs(command: string, args: unknown[]): Promise { + if (command === 'ansibleDevTools.selectEnvironment') { + const envId = extractSelectEnvironmentId(args); + if (!envId) { + return args; + } + const environments = await this._pythonEnvService.getEnvironments('all'); + const environment = environments.find((e) => e.envId.id === envId); + if (!environment) { + void vscode.window.showWarningMessage( + 'Could not find that Python environment. Refresh Environment Managers and try again.', + ); + return null; + } + return [environment]; + } + + if (!isPlaybookCommand(command)) { + return args; + } + + const wrapper = normalizePlaybookPayload(args); + if (!wrapper) { + return args; + } + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(wrapper.playbook.path)); + return [ + { + playbook: { + ...wrapper.playbook, + workspaceFolder: folder, + }, + }, + ]; + } + + /** + * Push NavTree state to the webview. + * Cold start uses a skeleton (headers first); later refreshes replace content in place. + * @param options - Optional push behaviour + * @param options.progressive - Force skeleton-first paint (default: only before first hydrate) + */ + private async _pushSnapshot(options?: { progressive?: boolean }): Promise { + if (!this._view) { + return; + } + + const generation = ++this._snapshotGeneration; + const progressive = options?.progressive ?? !this._hasHydrated; + + if (progressive) { + const enableAiFeatures = + vscode.workspace + .getConfiguration('ansibleEnvironments') + .get('enableAiFeatures') ?? true; + const lightspeedEnabled = + vscode.workspace.getConfiguration('ansible.lightspeed').get('enabled') ?? + false; + this._postSnapshot( + this._model.buildSkeletonSnapshot({ enableAiFeatures, lightspeedEnabled }), + ); + } + + try { + const snapshot = await this._buildSnapshot(); + // `_view` may clear while awaiting; `_postSnapshot` no-ops when disposed. + if (generation !== this._snapshotGeneration) { + return; + } + this._hasHydrated = true; + this._postSnapshot(preserveExpandedChildren(this._lastSnapshot, snapshot)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage(`Ansible failed to load: ${message}`); + } + } + + /** + * Post a snapshot to the webview (fire-and-forget). + * @param snapshot - Skeleton or full NavTree state + */ + private _postSnapshot(snapshot: SidebarSnapshot): void { + if (!this._view) { + return; + } + this._lastSnapshot = snapshot; + void this._view.webview.postMessage({ + method: 'sidebar/setState', + params: { snapshot }, + }); + } + + /** + * Hydrate SidebarModel from live services / providers. + * @returns Serialisable NavTree snapshot + */ + private async _buildSnapshot(): Promise { + const enableAiFeatures = + vscode.workspace + .getConfiguration('ansibleEnvironments') + .get('enableAiFeatures') ?? true; + const lightspeedEnabled = + vscode.workspace.getConfiguration('ansible.lightspeed').get('enabled') ?? + false; + + const pythonAvailable = this._pythonEnvService.isAvailable(); + const pythonEnvCapability = this._pythonEnvService.getCapability(); + const devTools = DevToolsService.getInstance(); + const collections = CollectionsService.getInstance(); + const creator = CreatorService.getInstance(); + const eeService = ExecutionEnvService.getInstance(); + const galaxy = GalaxyCollectionCache.getInstance(); + const github = GitHubCollectionCache.getInstance(); + const skillRegistry = SkillRegistry.getInstance(); + + const playbookWorkspaces: { + name: string; + path: string; + playbooks: Awaited>; + }[] = []; + for (const folder of vscode.workspace.workspaceFolders ?? []) { + try { + const found = await discoverPlaybooks(folder.uri.fsPath); + playbookWorkspaces.push({ + name: folder.name, + path: folder.uri.fsPath, + playbooks: found, + }); + } catch { + playbookWorkspaces.push({ + name: folder.name, + path: folder.uri.fsPath, + playbooks: [], + }); + } + } + let executionEnvironmentsError: string | undefined; + const executionEnvironmentsLoading = eeService.isLoading(); + if (!executionEnvironmentsLoading) { + try { + await eeService.loadExecutionEnvironments(); + } catch (error) { + executionEnvironmentsError = error instanceof Error ? error.message : String(error); + } + } + + const collectionsIndexing = collections.isLoading() || !collections.isLoaded(); + + const githubOrgs = vscode.workspace + .getConfiguration('ansibleEnvironments') + .get('githubCollectionOrgs') ?? [ + 'ansible', + 'ansible-collections', + 'redhat-cop', + ]; + + const galaxyFilter = this._collectionSources?.getGalaxyFilter(); + const galaxyListed = galaxyFilter + ? galaxy.search(galaxyFilter) + : galaxy.getTopCollections(10); + + let mcpConfigured: boolean | undefined; + let mcpIdeLabel: string | undefined; + if (enableAiFeatures && this._mcpTools) { + const status = getMcpStatus(this._context); + mcpConfigured = status.isConfigured; + mcpIdeLabel = mcpIdeDisplayName(status.ide); + } + + if (enableAiFeatures) { + await skillRegistry.ensureLoaded(); + } + + let lightspeedAuthenticated: boolean | undefined; + if (lightspeedEnabled) { + try { + const session = await vscode.authentication.getSession('auth-lightspeed', [], { + createIfNone: false, + }); + lightspeedAuthenticated = Boolean(session); + } catch { + lightspeedAuthenticated = false; + } + } + + const input = assembleSidebarInput({ + pythonAvailable, + pythonEnvCapability, + enableAiFeatures, + envManagers: this._envManagers.getSidebarEnvManagers(), + devTools: devTools.getPackages(), + hasDevTools: devTools.hasPackages(), + collections: Array.from(collections.getCollections().values()), + collectionsIndexing, + galaxy: { + totalCount: galaxy.getCollections().length, + isLoading: galaxy.isLoading(), + filter: galaxyFilter, + listed: galaxyListed, + }, + githubOrgs: githubOrgs.map((org) => ({ + org, + count: github.getCount(org), + isRefreshing: github.isRefreshing(org), + lastUpdated: github.getLastUpdated(org)?.toLocaleString(), + collections: github.getCollections(org), + })), + executionEnvironments: eeService.getExecutionEnvironments(), + executionEnvironmentsLoading, + executionEnvironmentsError, + creator: { + status: creator.getStatus(), + installedVersion: creator.getInstalledVersion(), + schema: creator.getSchema(), + }, + playbookWorkspaces, + mcpConfigured, + mcpIdeLabel, + mcpCategoryLabels: MCP_CATEGORY_LABELS, + mcpTools: + enableAiFeatures && this._mcpTools + ? this._mcpTools.getAllTools().map((t) => ({ + category: t.category, + tool: { name: t.tool.name, description: t.tool.description }, + examplePrompt: t.examplePrompt, + toolInfo: t, + })) + : undefined, + skillSources: enableAiFeatures ? skillRegistry.getSources() : undefined, + skills: enableAiFeatures ? skillRegistry.getAllSkills() : undefined, + lightspeedEnabled, + lightspeedAuthenticated, + }); + + return this._model.buildSnapshot(input); + } + + /** + * HTML shell for the NavTree webview (CSP + React entry). + * @param webview - Target webview for asWebviewUri + * @returns Document HTML + */ + private _getHtml(webview: vscode.Webview): string { + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview.js'), + ); + const codiconsUri = webview.asWebviewUri( + vscode.Uri.joinPath( + this._extensionUri, + 'node_modules', + '@vscode', + 'codicons', + 'dist', + 'codicon.css', + ), + ); + return buildSidebarNavTreeHtml({ + scriptUri: scriptUri.toString(), + codiconsUri: codiconsUri.toString(), + nonce: crypto.randomBytes(16).toString('hex'), + cspSource: webview.cspSource, + }); + } +} diff --git a/src/sidebar/mcpLabels.ts b/src/sidebar/mcpLabels.ts new file mode 100644 index 0000000000..d0d8badf37 --- /dev/null +++ b/src/sidebar/mcpLabels.ts @@ -0,0 +1,24 @@ +/** Human-readable MCP tool category labels for NavTree AI Tools section. */ +export const MCP_CATEGORY_LABELS: Record = { + getting_started: 'Getting Started', + discovery: 'Discovery', + generation: 'Task Generation', + execution: 'Execution Environments', + devtools: 'Dev Tools', + creator: 'Creator', +}; + +const IDE_DISPLAY_NAMES: Record = { + bob: 'IBM Bob', + cursor: 'Cursor', + vscode: 'VS Code Copilot', +}; + +/** + * Map a detected IDE id to the label shown in the NavTree AI Tools section. + * @param ide - Detected IDE key from MCP status + * @returns Display label (falls back to the raw id) + */ +export function mcpIdeDisplayName(ide: string): string { + return IDE_DISPLAY_NAMES[ide] ?? ide; +} diff --git a/src/sidebar/navTreeCommandArgs.ts b/src/sidebar/navTreeCommandArgs.ts new file mode 100644 index 0000000000..dd6c384124 --- /dev/null +++ b/src/sidebar/navTreeCommandArgs.ts @@ -0,0 +1,98 @@ +/** + * Pure helpers for enriching NavTree webview command args before executeCommand. + * Keeps {@link AnsibleNavTreeProvider} free of payload-shape logic. + */ + +export interface PlaybookCommandPayload { + playbook: { + path: string; + name: string; + relativePath: string; + plays: unknown[]; + }; +} + +const PLAYBOOK_COMMANDS = new Set([ + 'ansiblePlaybooks.run', + 'ansiblePlaybooks.runWithProgress', + 'ansiblePlaybooks.openPlaybook', + 'ansiblePlaybooks.editConfig', + 'ansiblePlaybooks.aiSummary', +]); + +/** + * Whether the command needs playbook payload enrichment (workspace folder). + * @param command - VS Code command id + * @returns True when the NavTree should attach workspaceFolder + */ +export function isPlaybookCommand(command: string): boolean { + return PLAYBOOK_COMMANDS.has(command); +} + +/** + * Normalize webview playbook args (including legacy `pb-` node ids). + * @param args - Raw args from the webview DTO + * @returns Normalized playbook wrapper, or undefined when not a playbook payload + */ +export function normalizePlaybookPayload(args: unknown[]): PlaybookCommandPayload | undefined { + let payload: unknown = args[0]; + + // Legacy / stale webview sent the tree node id string (pb-) + if (typeof payload === 'string' && payload.startsWith('pb-')) { + const filePath = payload.slice(3); + payload = { + playbook: { + name: basename(filePath), + path: filePath, + relativePath: filePath, + plays: [], + }, + }; + } + + if (!payload || typeof payload !== 'object' || !('playbook' in payload)) { + return undefined; + } + + const playbook = payload.playbook; + if (!playbook || typeof playbook !== 'object') { + return undefined; + } + const fields = playbook as Record; + if (typeof fields.path !== 'string') { + return undefined; + } + return { + playbook: { + path: fields.path, + name: typeof fields.name === 'string' ? fields.name : basename(fields.path), + relativePath: + typeof fields.relativePath === 'string' ? fields.relativePath : fields.path, + plays: Array.isArray(fields.plays) ? fields.plays : [], + }, + }; +} + +/** + * Extract envId from selectEnvironment webview args. + * @param args - Raw args from the webview DTO + * @returns envId string when present + */ +export function extractSelectEnvironmentId(args: unknown[]): string | undefined { + const first = args[0]; + if (first && typeof first === 'object' && 'envId' in first) { + return typeof first.envId === 'string' ? first.envId : undefined; + } + return undefined; +} + +/** + * Basename without importing Node path (keeps helper easy to unit-test). + * @param filePath - Absolute or relative path + * @returns Final path segment + */ +function basename(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const parts = normalized.split('/'); + return parts[parts.length - 1] || filePath; +} diff --git a/src/sidebar/navTreeHtml.ts b/src/sidebar/navTreeHtml.ts new file mode 100644 index 0000000000..8d1305fd57 --- /dev/null +++ b/src/sidebar/navTreeHtml.ts @@ -0,0 +1,45 @@ +/** + * Build the HTML shell for the Ansible NavTree webview (CSP + React entry). + * @param options - Resolved webview resource URIs and CSP nonce + * @param options.scriptUri - Webview URI for `dist/webview.js` + * @param options.codiconsUri - Webview URI for codicon.css + * @param options.nonce - CSP nonce for the script tag + * @param options.cspSource - Webview CSP source token + * @returns Document HTML + */ +export function buildSidebarNavTreeHtml(options: { + scriptUri: string; + codiconsUri: string; + nonce: string; + cspSource: string; +}): string { + const { scriptUri, codiconsUri, nonce, cspSource } = options; + const csp = [ + `default-src 'none'`, + `style-src ${cspSource} 'unsafe-inline'`, + `font-src ${cspSource}`, + `script-src 'nonce-${nonce}'`, + ].join('; '); + + return ` + + + + + + + + + +
    + + +`; +} diff --git a/src/statusBar/ansibleStatusBar.ts b/src/statusBar/ansibleStatusBar.ts index ed0ccb4f24..106eae5cb1 100644 --- a/src/statusBar/ansibleStatusBar.ts +++ b/src/statusBar/ansibleStatusBar.ts @@ -58,14 +58,16 @@ export class AnsibleStatusBar implements vscode.Disposable { */ constructor( context: vscode.ExtensionContext, - private readonly _client: LanguageClient, + private readonly _client: LanguageClient | undefined, private readonly _envService: PythonEnvironmentService, ) { this._item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); this._item.command = 'ansible.showDiagnostics'; context.subscriptions.push(this._item); - this._disposables.push(this._registerNotificationHandler()); + if (this._client) { + this._disposables.push(this._registerNotificationHandler()); + } this._disposables.push( this._envService.onDidChangeEnvironment(() => { @@ -84,7 +86,11 @@ export class AnsibleStatusBar implements vscode.Disposable { * @returns Disposable that unregisters the notification handler. */ private _registerNotificationHandler(): vscode.Disposable { - return this._client.onNotification( + const client = this._client; + if (!client) { + return { dispose: () => undefined }; + } + return client.onNotification( new NotificationType(METADATA_NOTIFICATION), (dataList: AnsibleMetadata[]) => { if (dataList.length > 0) { @@ -137,7 +143,7 @@ export class AnsibleStatusBar implements vscode.Disposable { * @returns True when the language server connection is active. */ public isLanguageServerRunning(): boolean { - return this._client.isRunning(); + return this._client?.isRunning() ?? false; } /** @@ -171,7 +177,7 @@ export class AnsibleStatusBar implements vscode.Disposable { * @param fileUri - The URI of the active file to fetch metadata for. */ private async _requestMetadata(fileUri: string): Promise { - if (!this._client.isRunning()) { + if (!this._client?.isRunning()) { return; } diff --git a/src/views/AnsibleDevToolsProvider.ts b/src/views/AnsibleDevToolsController.ts similarity index 60% rename from src/views/AnsibleDevToolsProvider.ts rename to src/views/AnsibleDevToolsController.ts index cd30aaf3f0..95fbccfe77 100644 --- a/src/views/AnsibleDevToolsProvider.ts +++ b/src/views/AnsibleDevToolsController.ts @@ -4,12 +4,10 @@ import type { DevToolPackage } from '@ansible/developer-services'; import type { PythonEnvironmentService } from '@src/services/PythonEnvironmentService'; import { log } from '@src/extension'; -/** Tree view provider for installed Ansible developer tool packages. */ -export class AnsibleDevToolsProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = - new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = - this._onDidChangeTreeData.event; +/** Controller for installed Ansible developer tool packages. */ +export class AnsibleDevToolsController { + private _onDidChange: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChange: vscode.Event = this._onDidChange.event; private _service: DevToolsService; private _envListener: vscode.Disposable | undefined; @@ -29,7 +27,7 @@ export class AnsibleDevToolsProvider implements vscode.TreeDataProvider { - log('AnsibleDevToolsProvider: environment changed, scheduling refresh'); + log('AnsibleDevToolsController: environment changed, scheduling refresh'); this._scheduleRefresh(); }); // initialize() has resolved, so the binDirResolver points at the // active venv. Refresh now to pick up already-installed tools. - log('AnsibleDevToolsProvider: initialized, running initial refresh'); + log('AnsibleDevToolsController: initialized, running initial refresh'); this._scheduleRefresh(); } catch (error) { log( - `AnsibleDevToolsProvider: init failed: ${error instanceof Error ? error.message : String(error)}`, + `AnsibleDevToolsController: init failed: ${error instanceof Error ? error.message : String(error)}`, ); } } @@ -76,34 +74,6 @@ export class AnsibleDevToolsProvider implements vscode.TreeDataProvider { - if (element) { - return Promise.resolve([]); - } - return Promise.resolve(this._service.getPackages()); - } - /** * Whether any developer tool packages are currently available. * @returns True when at least one package is installed @@ -119,7 +89,7 @@ export class AnsibleDevToolsProvider implements vscode.TreeDataProvider void): v extensionLog = logFn; } -// --------------------------------------------------------------------------- -// Source info (shared interface for both Galaxy and GitHub) -// --------------------------------------------------------------------------- - /** Metadata for a collection source (Galaxy or GitHub). */ export interface CollectionSourceInfo { type: 'galaxy' | 'github'; @@ -35,233 +31,28 @@ export interface CollectionSourceInfo { isRefreshing: boolean; } -// --------------------------------------------------------------------------- -// Tree node types -// --------------------------------------------------------------------------- - -type TreeNode = - | CollectionSourceNode - | GalaxyCollectionNode - | GalaxyPluginTypeNode - | GalaxyPluginNode - | GitHubCollectionNode - | GitHubPluginTypeNode - | GitHubPluginNode; - -/** Root-level node representing a collection source (Galaxy or GitHub org). */ -class CollectionSourceNode extends vscode.TreeItem { - public readonly nodeType = 'source' as const; - - /** - * @param source - Source metadata. - * @param galaxyFilter - Active filter string, if any. - * @param galaxyFilterResultCount - Number of matching results. - */ - constructor( - public readonly source: CollectionSourceInfo, - public readonly galaxyFilter?: string, - public readonly galaxyFilterResultCount?: number, - ) { - const isGalaxy = source.type === 'galaxy'; - super(source.name, vscode.TreeItemCollapsibleState.Collapsed); - - this.iconPath = new vscode.ThemeIcon(isGalaxy ? 'globe' : 'github'); - - if (source.isRefreshing) { - this.description = 'Refreshing...'; - } else if (isGalaxy && galaxyFilter) { - this.description = `filter: "${galaxyFilter}" (${String(galaxyFilterResultCount ?? 0)} results)`; - } else { - this.description = `${source.count.toLocaleString()} collections`; - } - - const tooltip = new vscode.MarkdownString(); - tooltip.appendMarkdown(`**${source.name}**\n\n`); - tooltip.appendMarkdown( - `**Type:** ${isGalaxy ? 'Ansible Galaxy' : 'GitHub Organization'}\n\n`, - ); - tooltip.appendMarkdown(`**Collections:** ${source.count.toLocaleString()}\n\n`); - if (source.lastUpdated) { - tooltip.appendMarkdown(`**Last Updated:** ${source.lastUpdated.toLocaleString()}`); - } - this.tooltip = tooltip; - - if (isGalaxy) { - this.contextValue = galaxyFilter - ? 'collectionSourceGalaxyFiltered' - : 'collectionSourceGalaxy'; - } else { - this.contextValue = 'collectionSourceGitHub'; - } - } -} - -/** Tree node representing a single Galaxy collection. */ -class GalaxyCollectionNode extends vscode.TreeItem { - public readonly nodeType = 'galaxyCollection' as const; - - /** @param collection - Galaxy collection metadata. */ - constructor(public readonly collection: GalaxyCollection) { - super( - `${collection.namespace}.${collection.name}`, - vscode.TreeItemCollapsibleState.Collapsed, - ); - this.description = collection.version ? `v${collection.version}` : ''; - this.iconPath = new vscode.ThemeIcon('library'); - this.contextValue = 'galaxyCollection'; - - const tooltip = new vscode.MarkdownString(); - tooltip.appendMarkdown(`**${collection.namespace}.${collection.name}**\n\n`); - if (collection.version) tooltip.appendMarkdown(`Version: ${collection.version}\n\n`); - tooltip.appendMarkdown(`Downloads: ${collection.downloadCount.toLocaleString()}\n\n`); - if (collection.deprecated) tooltip.appendMarkdown('*deprecated*\n\n'); - this.tooltip = tooltip; - } -} - -/** Tree node grouping plugins by type (module, lookup, etc.). */ -class GalaxyPluginTypeNode extends vscode.TreeItem { - public readonly nodeType = 'galaxyPluginType' as const; - - /** - * @param pluginType - Plugin type name. - * @param plugins - Plugins of this type. - * @param collection - Parent collection. - */ - constructor( - public readonly pluginType: string, - public readonly plugins: PluginInfo[], - public readonly collection: GalaxyCollection, - ) { - super(pluginType, vscode.TreeItemCollapsibleState.Collapsed); - this.description = `(${String(plugins.length)})`; - this.iconPath = new vscode.ThemeIcon('symbol-folder'); - this.contextValue = 'galaxyPluginType'; - } -} - -/** Leaf tree node representing a single Galaxy plugin. */ -class GalaxyPluginNode extends vscode.TreeItem { - public readonly nodeType = 'galaxyPlugin' as const; - - /** - * @param plugin - Plugin info. - * @param pluginType - Plugin type name. - * @param collection - Parent collection. - */ - constructor( - public readonly plugin: PluginInfo, - public readonly pluginType: string, - public readonly collection: GalaxyCollection, - ) { - super(plugin.name, vscode.TreeItemCollapsibleState.None); - this.description = plugin.shortDescription; - this.iconPath = new vscode.ThemeIcon('symbol-method'); - this.contextValue = 'galaxyPlugin'; - - const tooltip = new vscode.MarkdownString(); - tooltip.appendMarkdown(`**`); - tooltip.appendText(plugin.fullName); - tooltip.appendMarkdown(`** *(${pluginType})*\n\n`); - if (plugin.shortDescription) { - tooltip.appendText(plugin.shortDescription); - tooltip.appendMarkdown('\n\n'); - } - tooltip.appendMarkdown('Collection: '); - tooltip.appendText(`${collection.namespace}.${collection.name} v${collection.version}`); - this.tooltip = tooltip; - - this.command = { - command: 'ansibleCollectionSources.showGalaxyPluginDoc', - title: 'Show Plugin Documentation', - arguments: [this], - }; - } +/** Command argument for installing a Galaxy collection from the NavTree. */ +export interface GalaxyCollectionCommandArgs { + collection: Pick; } -/** Tree node representing a single GitHub collection (expandable to plugin types). */ -class GitHubCollectionNode extends vscode.TreeItem { - public readonly nodeType = 'githubCollection' as const; - - /** @param collection - GitHub collection metadata. */ - constructor(public readonly collection: GitHubCollection) { - super( - `${collection.namespace}.${collection.name}`, - vscode.TreeItemCollapsibleState.Collapsed, - ); - this.description = collection.version ? `v${collection.version}` : ''; - this.iconPath = new vscode.ThemeIcon('library'); - this.contextValue = 'githubCollection'; - - const tooltip = new vscode.MarkdownString(); - tooltip.appendMarkdown(`**${collection.namespace}.${collection.name}**\n\n`); - if (collection.version) tooltip.appendMarkdown(`Version: ${collection.version}\n\n`); - tooltip.appendMarkdown(`Org: ${collection.org}\n\n`); - if (collection.description) tooltip.appendMarkdown(collection.description); - this.tooltip = tooltip; - } +/** Command argument for opening Galaxy plugin documentation. */ +export interface GalaxyPluginDocCommandArgs { + collection: Pick; + plugin: PluginInfo; + pluginType: string; } -/** Tree node grouping plugins by type within a GitHub collection. */ -class GitHubPluginTypeNode extends vscode.TreeItem { - public readonly nodeType = 'githubPluginType' as const; - - /** - * @param pluginType - Plugin type name. - * @param plugins - Plugins of this type. - * @param collection - Parent GitHub collection. - */ - constructor( - public readonly pluginType: string, - public readonly plugins: PluginInfo[], - public readonly collection: GitHubCollection, - ) { - super(pluginType, vscode.TreeItemCollapsibleState.Collapsed); - this.description = `(${String(plugins.length)})`; - this.iconPath = new vscode.ThemeIcon('symbol-folder'); - this.contextValue = 'githubPluginType'; - } +/** Command argument for GitHub collection actions from the NavTree. */ +export interface GitHubCollectionCommandArgs { + collection: Pick; } -/** Leaf tree node representing a single GitHub-sourced plugin. */ -class GitHubPluginNode extends vscode.TreeItem { - public readonly nodeType = 'githubPlugin' as const; - - /** - * @param plugin - Plugin info. - * @param pluginType - Plugin type name. - * @param collection - Parent GitHub collection. - */ - constructor( - public readonly plugin: PluginInfo, - public readonly pluginType: string, - public readonly collection: GitHubCollection, - ) { - super(plugin.name, vscode.TreeItemCollapsibleState.None); - this.description = plugin.shortDescription; - this.iconPath = new vscode.ThemeIcon('symbol-method'); - this.contextValue = 'githubPlugin'; - - const tooltip = new vscode.MarkdownString(); - tooltip.appendMarkdown(`**`); - tooltip.appendText(plugin.fullName); - tooltip.appendMarkdown(`** *(${pluginType})*\n\n`); - if (plugin.shortDescription) { - tooltip.appendText(plugin.shortDescription); - tooltip.appendMarkdown('\n\n'); - } - tooltip.appendMarkdown('Collection: '); - tooltip.appendText(`${collection.namespace}.${collection.name} v${collection.version}`); - tooltip.appendMarkdown('\n\nSource: '); - tooltip.appendText(`${collection.org}/${repoNameFrom(collection.repository)}`); - this.tooltip = tooltip; - - this.command = { - command: 'ansibleCollectionSources.showGitHubPluginDoc', - title: 'Show Plugin Documentation', - arguments: [this], - }; - } +/** Command argument for opening GitHub plugin documentation. */ +export interface GitHubPluginDocCommandArgs { + collection: Pick; + plugin: PluginInfo; + pluginType: string; } /** @@ -273,14 +64,10 @@ function repoNameFrom(repository: string): string { return repository.split('/').pop() ?? repository; } -// --------------------------------------------------------------------------- -// Provider -// --------------------------------------------------------------------------- - -/** TreeDataProvider for the Collection Sources sidebar view. */ -export class CollectionSourcesProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; +/** NavTree data source and command controller for collection sources. */ +export class CollectionSourcesController { + private _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; private _galaxyCache: GalaxyCollectionCache; private _galaxyDocsCache: GalaxyDocsCache; @@ -311,7 +98,7 @@ export class CollectionSourcesProvider implements vscode.TreeDataProvider { - extensionLog('CollectionSourcesProvider: Refreshing all sources...'); + extensionLog('CollectionSourcesController: Refreshing all sources...'); this.refresh(); await this._galaxyCache.forceRefresh(); const orgs = this._getConfiguredOrgs(); @@ -364,7 +151,7 @@ export class CollectionSourcesProvider implements vscode.TreeDataProvider { - extensionLog(`CollectionSourcesProvider: Refreshing source: ${source.id}`); + extensionLog(`CollectionSourcesController: Refreshing source: ${source.id}`); this.refresh(); if (source.type === 'galaxy') { await this._galaxyCache.forceRefresh(); @@ -404,191 +191,6 @@ export class CollectionSourcesProvider implements vscode.TreeDataProvider { - if (!element) { - return Promise.resolve(this._getRootNodes()); - } - - if (element instanceof CollectionSourceNode) { - if (element.source.type === 'galaxy') { - return Promise.resolve(this._getGalaxyChildren()); - } - return Promise.resolve(this._getGitHubChildren(element.source.id)); - } - - if (element instanceof GalaxyCollectionNode) { - return this._getCollectionChildren(element.collection); - } - - if (element instanceof GalaxyPluginTypeNode) { - return Promise.resolve( - element.plugins.map( - (p) => new GalaxyPluginNode(p, element.pluginType, element.collection), - ), - ); - } - - if (element instanceof GitHubCollectionNode) { - return this._getGitHubCollectionChildren(element.collection); - } - - if (element instanceof GitHubPluginTypeNode) { - return Promise.resolve( - element.plugins.map( - (p) => new GitHubPluginNode(p, element.pluginType, element.collection), - ), - ); - } - - return Promise.resolve([]); - } - - /** - * Builds the root-level source nodes (Galaxy + GitHub orgs). - * @returns Array of root-level tree nodes. - */ - private _getRootNodes(): TreeNode[] { - const sources: TreeNode[] = []; - - const galaxyCount = this._galaxyCache.getCollections().length; - const filterResults = this._galaxyFilter - ? this._galaxyCache.search(this._galaxyFilter) - : undefined; - sources.push( - new CollectionSourceNode( - { - type: 'galaxy', - id: 'galaxy', - name: 'Ansible Galaxy', - count: galaxyCount, - lastUpdated: undefined, - isRefreshing: this._galaxyCache.isLoading(), - }, - this._galaxyFilter, - filterResults?.length, - ), - ); - - const orgs = this._getConfiguredOrgs(); - for (const org of orgs) { - sources.push( - new CollectionSourceNode({ - type: 'github', - id: org, - name: org, - count: this._githubCache.getCount(org), - lastUpdated: this._githubCache.getLastUpdated(org), - isRefreshing: this._githubCache.isRefreshing(org), - }), - ); - } - - return sources; - } - - /** - * Returns Galaxy collection nodes, filtered or top-10. - * @returns Array of Galaxy collection tree nodes. - */ - private _getGalaxyChildren(): TreeNode[] { - const collections = this._galaxyFilter - ? this._galaxyCache.search(this._galaxyFilter) - : this._galaxyCache.getTopCollections(10); - return collections.map((c) => new GalaxyCollectionNode(c)); - } - - /** - * Expands a Galaxy collection into plugin-type groupings. - * @param collection - The collection to expand. - * @returns Array of plugin-type tree nodes, or an error placeholder on failure. - */ - private async _getCollectionChildren(collection: GalaxyCollection): Promise { - const pluginTypes = await this._galaxyDocsCache.getPluginTypes( - collection.namespace, - collection.name, - collection.version, - ); - - if (!pluginTypes) { - const errorNode = new vscode.TreeItem( - 'Failed to load plugin documentation', - vscode.TreeItemCollapsibleState.None, - ); - errorNode.iconPath = new vscode.ThemeIcon('warning'); - errorNode.tooltip = `Could not fetch docs-blob for ${collection.namespace}.${collection.name} v${collection.version}. Click the collection to retry.`; - return [errorNode as TreeNode]; - } - - return Object.entries(pluginTypes) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([type, plugins]) => new GalaxyPluginTypeNode(type, plugins, collection)); - } - - /** - * Returns GitHub collection nodes for an org. - * @param org - GitHub organization name. - * @returns Array of GitHub collection tree nodes. - */ - private _getGitHubChildren(org: string): TreeNode[] { - return this._githubCache - .getCollections(org) - .slice() - .sort((a, b) => `${a.namespace}.${a.name}`.localeCompare(`${b.namespace}.${b.name}`)) - .map((c) => new GitHubCollectionNode(c)); - } - - /** - * Expands a GitHub collection into plugin-type groupings via SCMDocsCache. - * @param collection - The GitHub collection to expand. - * @returns Array of plugin-type tree nodes, or an error placeholder on failure. - */ - private async _getGitHubCollectionChildren(collection: GitHubCollection): Promise { - const pluginTypes = await this._scmDocsCache.getPluginTypes( - collection.org, - repoNameFrom(collection.repository), - collection.namespace, - collection.name, - ); - - if (!pluginTypes) { - const errorNode = new vscode.TreeItem( - 'Failed to load plugin documentation', - vscode.TreeItemCollapsibleState.None, - ); - errorNode.iconPath = new vscode.ThemeIcon('warning'); - errorNode.tooltip = - `Could not index ${collection.namespace}.${collection.name}. ` + - 'Requires git and ansible-doc on PATH. Click to retry.'; - return [errorNode as TreeNode]; - } - - return Object.entries(pluginTypes) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([type, plugins]) => new GitHubPluginTypeNode(type, plugins, collection)); - } - - // ------------------------------------------------------------------- - // Galaxy filter - // ------------------------------------------------------------------- - /** Prompts for a filter string and applies it to the Galaxy subtree. */ public async filterGalaxyCollections(): Promise { const query = await vscode.window.showInputBox({ @@ -609,16 +211,20 @@ export class CollectionSourcesProvider implements vscode.TreeDataProvider { - const fqcn = `${node.collection.namespace}.${node.collection.name}`; + public async installGalaxyCollection(args: GalaxyCollectionCommandArgs): Promise { + const fqcn = `${args.collection.namespace}.${args.collection.name}`; await this._installCollection(fqcn, 'galaxy'); } @@ -734,7 +340,7 @@ export class CollectionSourcesProvider implements vscode.TreeDataProvider { - extensionLog(`CollectionSourcesProvider: Installing ${installUrl} from ${sourceType}`); + extensionLog(`CollectionSourcesController: Installing ${installUrl} from ${sourceType}`); await vscode.window.withProgress( { @@ -760,20 +366,16 @@ export class CollectionSourcesProvider implements vscode.TreeDataProvider { - const { collection, plugin, pluginType } = node; + const { collection, plugin, pluginType } = args; await vscode.window.withProgress( { @@ -804,14 +406,14 @@ export class CollectionSourcesProvider implements vscode.TreeDataProvider { - const { collection, plugin, pluginType } = node; + const { collection, plugin, pluginType } = args; await vscode.window.withProgress( { @@ -843,10 +445,10 @@ export class CollectionSourcesProvider implements vscode.TreeDataProvider { const query = await vscode.window.showInputBox({ @@ -960,10 +558,6 @@ export class CollectionSourcesProvider implements vscode.TreeDataProvider { const orgs = this._getConfiguredOrgs(); diff --git a/src/views/CollectionsController.ts b/src/views/CollectionsController.ts new file mode 100644 index 0000000000..9f52f1ff1a --- /dev/null +++ b/src/views/CollectionsController.ts @@ -0,0 +1,83 @@ +import * as vscode from 'vscode'; +import { CollectionsService } from '@ansible/developer-services'; +import type { PythonEnvironmentService } from '@src/services/PythonEnvironmentService'; +import { log } from '@src/extension'; + +/** Controller for installed Ansible collections and their plugins. */ +export class CollectionsController { + private _onDidChange: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChange: vscode.Event = this._onDidChange.event; + + private _service: CollectionsService; + private _envListener: vscode.Disposable | undefined; + private _serviceListener: vscode.Disposable | undefined; + private _refreshDebounce: ReturnType | undefined; + + /** + * Create the provider and refresh when collections or the environment change. + * @param pythonEnvService - Service that reports Python environment changes + */ + constructor(pythonEnvService: PythonEnvironmentService) { + this._service = CollectionsService.getInstance(); + + this._serviceListener = (this._service.onDidChange as vscode.Event)(() => { + this._onDidChange.fire(undefined); + }); + + log('CollectionsController: Triggering initial refresh'); + void this.refresh().catch((err: unknown) => { + log( + `CollectionsController: Initial refresh failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + + void this._initEnvListener(pythonEnvService); + } + + /** + * Debounce collection refreshes when the active Python environment changes. + * @param pythonEnvService - Service used to observe environment changes + */ + private async _initEnvListener(pythonEnvService: PythonEnvironmentService) { + try { + await pythonEnvService.initialize(); + + this._envListener = pythonEnvService.onDidChangeEnvironment(() => { + if (this._refreshDebounce) { + clearTimeout(this._refreshDebounce); + } + this._refreshDebounce = setTimeout(() => { + this._refreshDebounce = undefined; + void this.forceRefresh(); + }, 1000); + }); + } catch (error) { + log( + `CollectionsController: Failed to set up env change listener: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** Reload installed collections and plugin metadata (may use cache). */ + async refresh(): Promise { + await this._service.refresh(); + } + + /** + * Bypass cache and re-index from ansible-doc / ade in the active env. + * Use after env switch or ansible-dev-tools install. + */ + async forceRefresh(): Promise { + await this._service.forceRefresh(); + } + + /** Release listeners and pending refresh timers. */ + dispose() { + if (this._refreshDebounce) { + clearTimeout(this._refreshDebounce); + } + this._envListener?.dispose(); + this._serviceListener?.dispose(); + this._onDidChange.dispose(); + } +} diff --git a/src/views/CollectionsProvider.ts b/src/views/CollectionsProvider.ts deleted file mode 100644 index 5f814cde42..0000000000 --- a/src/views/CollectionsProvider.ts +++ /dev/null @@ -1,228 +0,0 @@ -import * as vscode from 'vscode'; -import { CollectionsService, CollectionInfo, PluginInfo } from '@ansible/developer-services'; -import type { PythonEnvironmentService } from '@src/services/PythonEnvironmentService'; -import { log } from '@src/extension'; - -type TreeNode = CollectionNode | PluginTypeNode | PluginNode | LoadingNode; - -interface LoadingNode { - type: 'loading'; -} - -interface CollectionNode { - type: 'collection'; - name: string; - info: CollectionInfo; - pluginTypes: Map; -} - -interface PluginTypeNode { - type: 'pluginType'; - name: string; - collectionName: string; - plugins: PluginInfo[]; -} - -interface PluginNode { - type: 'plugin'; - name: string; - fullName: string; - shortDescription: string; - pluginType: string; -} - -/** Tree view provider for installed Ansible collections and their plugins. */ -export class CollectionsProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = - new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = - this._onDidChangeTreeData.event; - - private _service: CollectionsService; - private _envListener: vscode.Disposable | undefined; - private _serviceListener: vscode.Disposable | undefined; - private _refreshDebounce: ReturnType | undefined; - - /** - * Create the provider and refresh when collections or the environment change. - * @param pythonEnvService - Service that reports Python environment changes - */ - constructor(pythonEnvService: PythonEnvironmentService) { - this._service = CollectionsService.getInstance(); - - this._serviceListener = (this._service.onDidChange as vscode.Event)(() => { - this._onDidChangeTreeData.fire(undefined); - }); - - log('CollectionsProvider: Triggering initial refresh'); - void this.refresh().catch((err: unknown) => { - log( - `CollectionsProvider: Initial refresh failed: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - - void this._initEnvListener(pythonEnvService); - } - - /** - * Debounce collection refreshes when the active Python environment changes. - * @param pythonEnvService - Service used to observe environment changes - */ - private async _initEnvListener(pythonEnvService: PythonEnvironmentService) { - try { - await pythonEnvService.initialize(); - - this._envListener = pythonEnvService.onDidChangeEnvironment(() => { - if (this._refreshDebounce) { - clearTimeout(this._refreshDebounce); - } - this._refreshDebounce = setTimeout(() => { - this._refreshDebounce = undefined; - void this.refresh(); - }, 1000); - }); - } catch (error) { - log( - `CollectionsProvider: Failed to set up env change listener: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - /** Reload installed collections and plugin metadata. */ - async refresh(): Promise { - await this._service.refresh(); - } - - /** - * Render a collection, plugin type, plugin, or loading node. - * @param element - Tree node to display - * @returns Tree item with labels, icons, and commands for plugin nodes - */ - getTreeItem(element: TreeNode): vscode.TreeItem { - if (element.type === 'loading') { - const item = new vscode.TreeItem( - 'Indexing collections...', - vscode.TreeItemCollapsibleState.None, - ); - item.iconPath = new vscode.ThemeIcon('sync~spin'); - item.tooltip = 'Scanning installed collections and plugins. This may take a moment.'; - return item; - } - - if (element.type === 'collection') { - const item = new vscode.TreeItem( - element.name, - vscode.TreeItemCollapsibleState.Collapsed, - ); - item.description = element.info.version ? `v${element.info.version}` : undefined; - item.iconPath = new vscode.ThemeIcon('library'); - item.contextValue = 'collection'; - - const tooltipParts: string[] = [`**${element.name}**`]; - if (element.info.version) { - tooltipParts.push(`\n\nVersion: ${element.info.version}`); - } - if (element.info.authors.length > 0) { - tooltipParts.push(`\n\nAuthors: ${element.info.authors.join(', ')}`); - } - if (element.info.description) { - tooltipParts.push(`\n\n${element.info.description}`); - } - if (element.info.path) { - tooltipParts.push(`\n\n---\n\nPath: \`${element.info.path}\``); - } - item.tooltip = new vscode.MarkdownString(tooltipParts.join('')); - - return item; - } else if (element.type === 'pluginType') { - const item = new vscode.TreeItem( - element.name, - vscode.TreeItemCollapsibleState.Collapsed, - ); - item.description = `(${String(element.plugins.length)})`; - item.iconPath = new vscode.ThemeIcon('symbol-folder'); - item.contextValue = 'pluginType'; - return item; - } else { - const item = new vscode.TreeItem(element.name, vscode.TreeItemCollapsibleState.None); - item.description = element.shortDescription; - item.iconPath = new vscode.ThemeIcon('symbol-method'); - item.contextValue = 'plugin'; - item.tooltip = new vscode.MarkdownString( - `**${element.fullName}**\n\n${element.shortDescription}\n\n*Click to view documentation*`, - ); - item.command = { - command: 'ansibleDevTools.showPluginDoc', - title: 'Show Plugin Documentation', - arguments: [element.fullName, element.pluginType], - }; - return item; - } - } - - /** - * Return root collections or the children of a collection or plugin type node. - * @param element - Parent node whose children should be listed - * @returns Child nodes for the requested tree level - */ - getChildren(element?: TreeNode): Thenable { - if (!element) { - if (this._service.isLoading() || !this._service.isLoaded()) { - log( - `CollectionsProvider: getChildren - loading=${String(this._service.isLoading())}, loaded=${String(this._service.isLoaded())}`, - ); - return Promise.resolve([{ type: 'loading' }]); - } - - const collections: CollectionNode[] = []; - const serviceCollections = this._service.getCollections(); - log( - `CollectionsProvider: getChildren - service has ${String(serviceCollections.size)} collections`, - ); - - for (const [name, data] of serviceCollections) { - collections.push({ - type: 'collection', - name, - info: data.info, - pluginTypes: data.pluginTypes, - }); - } - collections.sort((a, b) => a.name.localeCompare(b.name)); - return Promise.resolve(collections); - } else if (element.type === 'collection') { - const pluginTypes: PluginTypeNode[] = []; - for (const [typeName, plugins] of element.pluginTypes) { - pluginTypes.push({ - type: 'pluginType', - name: typeName, - collectionName: element.name, - plugins, - }); - } - pluginTypes.sort((a, b) => a.name.localeCompare(b.name)); - return Promise.resolve(pluginTypes); - } else if (element.type === 'pluginType') { - const plugins: PluginNode[] = element.plugins.map((p) => ({ - type: 'plugin', - name: p.name, - fullName: p.fullName, - shortDescription: p.shortDescription, - pluginType: element.name, - })); - return Promise.resolve(plugins); - } - - return Promise.resolve([]); - } - - /** Release listeners and pending refresh timers. */ - dispose() { - if (this._refreshDebounce) { - clearTimeout(this._refreshDebounce); - } - this._envListener?.dispose(); - this._serviceListener?.dispose(); - this._onDidChangeTreeData.dispose(); - } -} diff --git a/src/views/CreatorController.ts b/src/views/CreatorController.ts new file mode 100644 index 0000000000..75becef1b1 --- /dev/null +++ b/src/views/CreatorController.ts @@ -0,0 +1,46 @@ +import * as vscode from 'vscode'; +import { CreatorService } from '@ansible/developer-services'; +import type { SchemaNode } from '@ansible/developer-services'; +import { log } from '@src/extension'; + +/** Controller for ansible-creator init and add commands. */ +export class CreatorController { + private _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; + + private _service: CreatorService; + private _serviceListener: vscode.Disposable | undefined; + + /** Create the controller and load the ansible-creator schema. */ + constructor() { + this._service = CreatorService.getInstance(); + this._service.setLogFunction(log); + + // Listen for service changes + this._serviceListener = (this._service.onDidChange as vscode.Event)(() => { + this._onDidChange.fire(undefined); + }); + + // Initial load + void this._service.loadSchema(); + } + + /** Reload the ansible-creator schema and notify NavTree listeners. */ + refresh(): void { + void this._service.refresh(); + } + + /** + * Expose the loaded ansible-creator schema for other extension components. + * @returns The current creator schema, or null when unavailable + */ + public getSchema(): SchemaNode | null { + return this._service.getSchema(); + } + + /** Release service listeners and change event emitters. */ + dispose() { + this._serviceListener?.dispose(); + this._onDidChange.dispose(); + } +} diff --git a/src/views/CreatorProvider.ts b/src/views/CreatorProvider.ts deleted file mode 100644 index 0698c6013f..0000000000 --- a/src/views/CreatorProvider.ts +++ /dev/null @@ -1,267 +0,0 @@ -import * as vscode from 'vscode'; -import { CreatorService } from '@ansible/developer-services'; -import type { SchemaNode } from '@ansible/developer-services'; -import { log } from '@src/extension'; - -type TreeNode = CategoryNode | CommandNode | MessageNode; - -/** Tree item that displays informational or actionable creator messages. */ -class MessageNode extends vscode.TreeItem { - /** - * Create a non-collapsible message node for the creator tree. - * @param label - Primary message text shown in the tree - * @param options - Optional presentation and command settings - * @param options.description - Secondary text shown inline in the tree - * @param options.tooltip - Hover text with additional detail - * @param options.icon - Theme icon name for the node - * @param options.command - VS Code command launched when the node is clicked - */ - constructor( - label: string, - options?: { - description?: string; - tooltip?: string; - icon?: string; - command?: vscode.Command; - }, - ) { - super(label, vscode.TreeItemCollapsibleState.None); - this.contextValue = 'creatorMessage'; - if (options?.description) { - this.description = options.description; - } - if (options?.tooltip) { - this.tooltip = options.tooltip; - } - this.iconPath = new vscode.ThemeIcon(options?.icon ?? 'info'); - if (options?.command) { - this.command = options.command; - } - } -} - -/** Collapsible tree item representing a creator schema category. */ -class CategoryNode extends vscode.TreeItem { - /** - * Create a category node that groups related creator commands. - * @param label - Category label shown in the tree - * @param children - Child nodes contained in this category - * @param commandPath - Schema path prefix for commands in this category - * @param description - Optional tooltip description from the schema - */ - constructor( - public readonly label: string, - public readonly children: TreeNode[], - public readonly commandPath: string[], - description?: string, - ) { - super(label, vscode.TreeItemCollapsibleState.Collapsed); - this.contextValue = 'creatorCategory'; - this.iconPath = new vscode.ThemeIcon('folder'); - - // Use description for tooltip - if (description) { - this.tooltip = description; - } - } -} - -/** Leaf tree item that opens a creator form for a schema command. */ -class CommandNode extends vscode.TreeItem { - /** - * Create a command node that launches the creator form on click. - * @param label - Command label shown in the tree - * @param schema - Schema definition for the command form - * @param commandPath - Schema path used to invoke ansible-creator - */ - constructor( - public readonly label: string, - public readonly schema: SchemaNode, - public readonly commandPath: string[], - ) { - super(label, vscode.TreeItemCollapsibleState.None); - this.contextValue = 'creatorCommand'; - this.iconPath = new vscode.ThemeIcon('new-file'); - - // Use description for tooltip (full text on hover) - const desc = schema.description ?? ''; - this.tooltip = desc || label; - - // Show truncated description inline if available - if (desc && desc.length > 0) { - // Truncate long descriptions for inline display - this.description = desc.length > 50 ? desc.substring(0, 47) + '...' : desc; - } - - // Click to open form - this.command = { - command: 'ansibleCreator.openForm', - title: 'Open Creator Form', - arguments: [commandPath, schema], - }; - } -} - -/** Tree view provider for ansible-creator init and add commands. */ -export class CreatorProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - private _service: CreatorService; - private _serviceListener: vscode.Disposable | undefined; - - /** Create the provider and load the ansible-creator schema. */ - constructor() { - this._service = CreatorService.getInstance(); - this._service.setLogFunction(log); - - // Listen for service changes - this._serviceListener = (this._service.onDidChange as vscode.Event)(() => { - this._onDidChangeTreeData.fire(undefined); - }); - - // Initial load - void this._service.loadSchema(); - } - - /** Reload the ansible-creator schema and refresh the tree. */ - refresh(): void { - void this._service.refresh(); - } - - /** - * Return the tree item for a creator node. - * @param element - Node whose tree item should be displayed - * @returns The node itself because creator nodes extend TreeItem - */ - getTreeItem(element: TreeNode): vscode.TreeItem { - return element; - } - - /** - * Return root categories or the children of a category node. - * @param element - Parent node whose children should be listed - * @returns Creator categories, commands, or status messages - */ - getChildren(element?: TreeNode): TreeNode[] | Promise { - if (!element) { - // Root level - show Init and Add categories - if (this._service.isLoading()) { - return [new MessageNode('Loading...', { icon: 'sync~spin' })]; - } - - const schema = this._service.getSchema(); - if (!schema) { - const status = this._service.getStatus(); - if (status === 'outdated') { - const ver = this._service.getInstalledVersion(); - return [ - new MessageNode('ansible-creator outdated', { - description: ver ? `v${ver} — upgrade required` : 'Upgrade required', - tooltip: - 'The installed ansible-creator does not support the "schema" subcommand.\nUpgrade ansible-dev-tools to get the latest version.', - icon: 'warning', - command: { - command: 'ansibleDevToolsPackages.upgrade', - title: 'Upgrade ansible-dev-tools', - }, - }), - ]; - } - return [ - new MessageNode('ansible-creator not found', { - description: 'Click to install', - tooltip: 'Install ansible-dev-tools to enable Creator features', - icon: 'warning', - command: { - command: 'ansibleDevToolsPackages.install', - title: 'Install ansible-dev-tools', - }, - }), - ]; - } - - const children: TreeNode[] = []; - - // Add "Init" category - if (schema.subcommands?.init) { - const initNode = this._buildCategoryNode('Init', schema.subcommands.init, ['init']); - children.push(initNode); - } - - // Add "Add" category - if (schema.subcommands?.add) { - const addNode = this._buildCategoryNode('Add', schema.subcommands.add, ['add']); - children.push(addNode); - } - - return children; - } - - if (element instanceof CategoryNode) { - return element.children; - } - - return []; - } - - /** - * Build a category node and recursively attach child commands or subcategories. - * @param label - Category label shown in the tree - * @param schema - Schema subtree for this category - * @param path - Command path prefix accumulated from parent categories - * @returns A populated category node for the creator tree - */ - private _buildCategoryNode(label: string, schema: SchemaNode, path: string[]): CategoryNode { - const children: TreeNode[] = []; - - if (schema.subcommands) { - for (const [name, subSchema] of Object.entries(schema.subcommands)) { - const subPath = [...path, name]; - - // Check if this has further subcommands - if (subSchema.subcommands && Object.keys(subSchema.subcommands).length > 0) { - // It's a category with more children - const categoryNode = this._buildCategoryNode( - this._formatLabel(name), - subSchema, - subPath, - ); - children.push(categoryNode); - } else { - // It's a leaf command - children.push(new CommandNode(this._formatLabel(name), subSchema, subPath)); - } - } - } - - return new CategoryNode(label, children, path, schema.description); - } - - /** - * Convert a schema key into a human-readable tree label. - * @param name - Schema key in snake_case or kebab-case - * @returns Title-cased label for display in the tree - */ - private _formatLabel(name: string): string { - // Convert snake_case to Title Case - return name - .split(/[_-]/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - } - - /** - * Expose the loaded ansible-creator schema for other extension components. - * @returns The current creator schema, or null when unavailable - */ - public getSchema(): SchemaNode | null { - return this._service.getSchema(); - } - - /** Release service listeners and tree event emitters. */ - dispose() { - this._serviceListener?.dispose(); - this._onDidChangeTreeData.dispose(); - } -} diff --git a/src/views/EnvironmentManagersController.ts b/src/views/EnvironmentManagersController.ts new file mode 100644 index 0000000000..994ec81f9f --- /dev/null +++ b/src/views/EnvironmentManagersController.ts @@ -0,0 +1,213 @@ +import * as vscode from 'vscode'; +import type { PythonEnvironment, SidebarEnvManagerInput } from '@ansible/developer-services'; +import type { PythonEnvironmentService } from '@src/services/PythonEnvironmentService'; +import { log } from '@src/extension'; + +/** NavTree data source for Python environment managers and their environments. */ +export class EnvironmentManagersController { + private _onDidChange: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChange: vscode.Event = this._onDidChange.event; + + private _pythonEnvService: PythonEnvironmentService; + private _managers = new Map(); + private _envListener: vscode.Disposable | undefined; + private _currentExecPath: string | undefined; + private _currentManagerId: string | undefined; + private _refreshDebounce: ReturnType | undefined; + + /** + * Create the provider and load environments from the Python extension. + * @param pythonEnvService - Service used to discover Python environments + */ + constructor(pythonEnvService: PythonEnvironmentService) { + this._pythonEnvService = pythonEnvService; + void this._init(); + } + + /** Initialize environment discovery and subscribe to environment changes. */ + private async _init() { + try { + await this._pythonEnvService.initialize(); + + this._envListener = this._pythonEnvService.onDidChangeEnvironment(() => { + this._debouncedRefresh(); + }); + + await this._doRefresh(); + } catch (error) { + console.error('Failed to initialize EnvironmentManagersController:', error); + } + } + + /** + * Reload Python environments and refresh the tree. + * @returns Resolves when the refresh is complete + */ + async refresh(): Promise { + return this._doRefresh(); + } + + /** + * Debounce rapid-fire change events so we only refresh once after the + * Python extension finishes its discovery burst. + */ + private _debouncedRefresh(): void { + if (this._refreshDebounce) { + clearTimeout(this._refreshDebounce); + } + this._refreshDebounce = setTimeout(() => { + this._refreshDebounce = undefined; + void this._doRefresh(); + }, 500); + } + + /** Reload environments and notify tree listeners of the update. */ + private async _doRefresh(): Promise { + try { + await this._loadEnvironments(); + } finally { + this._onDidChange.fire(undefined); + } + } + + /** Populate manager groups from the Python environment service. */ + private async _loadEnvironments(): Promise { + const managers = new Map(); + + if (!this._pythonEnvService.isAvailable()) { + this._managers = managers; + return; + } + + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri; + const currentEnv = await this._pythonEnvService.getEnvironment(workspaceFolder); + this._currentExecPath = currentEnv?.execInfo.run.executable; + this._currentManagerId = currentEnv?.envId.managerId; + + const environments = await this._pythonEnvService.getEnvironments('all'); + + for (const env of environments) { + const managerId = env.envId.managerId; + if (!managers.has(managerId)) { + managers.set(managerId, []); + } + managers.get(managerId)?.push(env); + } + } catch (error) { + log(`Failed to load environments: ${String(error)}`); + } + + this._managers = managers; + } + + /** + * Whether the environment matches the currently selected interpreter. + * @param env - Python environment to compare against the active selection + * @returns True when the environment executable matches the current one + */ + private _isCurrent(env: PythonEnvironment): boolean { + if (!this._currentExecPath) { + return false; + } + return env.execInfo.run.executable === this._currentExecPath; + } + + /** + * Convert a manager identifier into a user-friendly tree label. + * @param managerId - Raw manager ID from the Python extension + * @returns Display name for the environment manager group + */ + private _getManagerDisplayName(managerId: string): string { + const parts = managerId.split(':'); + const name = parts[parts.length - 1] || managerId; + const lower = name.toLowerCase(); + + // Map known tool/type names to friendly labels + if (lower === 'system' || lower === 'unknown') { + return 'Global'; + } + if (lower === 'venv' || lower === 'virtualenv') { + return 'venv'; + } + if (lower === 'conda') { + return 'Conda'; + } + if (lower === 'pyenv') { + return 'Pyenv'; + } + if (lower === 'poetry') { + return 'Poetry'; + } + if (lower === 'pipenv') { + return 'Pipenv'; + } + + return name.charAt(0).toUpperCase() + name.slice(1); + } + + /** + * Whether the manager ID represents a virtual environment manager. + * @param managerId - Raw manager ID from the Python extension + * @returns True for venv-style managers + */ + private _isVenvManager(managerId: string): boolean { + const name = managerId.split(':').pop()?.toLowerCase() ?? ''; + return name === 'venv' || name === 'virtualenv' || name === 'virtualenvwrapper'; + } + + /** + * Whether the manager ID represents a global/system Python installation. + * @param managerId - Raw manager ID from the Python extension + * @returns True for global or unknown system managers + */ + private _isGlobalManager(managerId: string): boolean { + const name = managerId.split(':').pop()?.toLowerCase() ?? ''; + return name === 'system' || name === 'unknown'; + } + + /** + * Export manager/env rows for the host-agnostic sidebar NavTree (ADR-025). + * @returns Plain env-manager input without vscode types + */ + getSidebarEnvManagers(): SidebarEnvManagerInput[] { + const managers: SidebarEnvManagerInput[] = []; + for (const [managerId, environments] of this._managers) { + const isGlobal = this._isGlobalManager(managerId); + managers.push({ + id: managerId, + name: this._getManagerDisplayName(managerId), + isGlobal, + environments: environments.map((env) => ({ + id: env.envId.id, + label: env.displayName || env.name, + version: env.version, + path: env.sysPrefix || env.displayPath, + selected: this._isCurrent(env), + warning: isGlobal, + })), + }); + } + managers.sort((a, b) => { + const aIsVenv = this._isVenvManager(a.id); + const bIsVenv = this._isVenvManager(b.id); + if (aIsVenv !== bIsVenv) { + return aIsVenv ? -1 : 1; + } + if (a.isGlobal !== b.isGlobal) { + return a.isGlobal ? 1 : -1; + } + return a.name.localeCompare(b.name); + }); + return managers; + } + + /** Release listeners and pending refresh timers. */ + dispose() { + if (this._refreshDebounce) { + clearTimeout(this._refreshDebounce); + } + this._envListener?.dispose(); + this._onDidChange.dispose(); + } +} diff --git a/src/views/EnvironmentManagersProvider.ts b/src/views/EnvironmentManagersProvider.ts deleted file mode 100644 index 07bd733b39..0000000000 --- a/src/views/EnvironmentManagersProvider.ts +++ /dev/null @@ -1,326 +0,0 @@ -import * as vscode from 'vscode'; -import type { PythonEnvironment } from '@ansible/developer-services'; -import type { PythonEnvironmentService } from '@src/services/PythonEnvironmentService'; - -type TreeNode = ManagerNode | EnvironmentNode; - -interface ManagerNode { - type: 'manager'; - id: string; - name: string; - environments: PythonEnvironment[]; -} - -interface EnvironmentNode { - type: 'environment'; - environment: PythonEnvironment; - managerId: string; -} - -/** Tree view provider for Python environment managers and their environments. */ -export class EnvironmentManagersProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = - new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = - this._onDidChangeTreeData.event; - - private _pythonEnvService: PythonEnvironmentService; - private _managers = new Map(); - private _envListener: vscode.Disposable | undefined; - private _currentExecPath: string | undefined; - private _currentManagerId: string | undefined; - private _refreshDebounce: ReturnType | undefined; - - /** - * Create the provider and load environments from the Python extension. - * @param pythonEnvService - Service used to discover Python environments - */ - constructor(pythonEnvService: PythonEnvironmentService) { - this._pythonEnvService = pythonEnvService; - void this._init(); - } - - /** Initialize environment discovery and subscribe to environment changes. */ - private async _init() { - try { - await this._pythonEnvService.initialize(); - - this._envListener = this._pythonEnvService.onDidChangeEnvironment(() => { - this._debouncedRefresh(); - }); - - await this._doRefresh(); - } catch (error) { - console.error('Failed to initialize EnvironmentManagersProvider:', error); - } - } - - /** - * Reload Python environments and refresh the tree. - * @returns Resolves when the refresh is complete - */ - async refresh(): Promise { - return this._doRefresh(); - } - - /** - * Debounce rapid-fire change events so we only refresh once after the - * Python extension finishes its discovery burst. - */ - private _debouncedRefresh(): void { - if (this._refreshDebounce) { - clearTimeout(this._refreshDebounce); - } - this._refreshDebounce = setTimeout(() => { - this._refreshDebounce = undefined; - void this._doRefresh(); - }, 500); - } - - /** Reload environments and notify tree listeners of the update. */ - private async _doRefresh(): Promise { - try { - await this._loadEnvironments(); - } finally { - this._onDidChangeTreeData.fire(undefined); - } - } - - /** Populate manager groups from the Python environment service. */ - private async _loadEnvironments(): Promise { - const managers = new Map(); - - if (!this._pythonEnvService.isAvailable()) { - this._managers = managers; - return; - } - - try { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri; - const currentEnv = await this._pythonEnvService.getEnvironment(workspaceFolder); - this._currentExecPath = currentEnv?.execInfo.run.executable; - this._currentManagerId = currentEnv?.envId.managerId; - - const environments = await this._pythonEnvService.getEnvironments('all'); - - for (const env of environments) { - const managerId = env.envId.managerId; - if (!managers.has(managerId)) { - managers.set(managerId, []); - } - managers.get(managerId)?.push(env); - } - } catch (error) { - console.error('Failed to load environments:', error); - } - - this._managers = managers; - } - - /** - * Whether the environment matches the currently selected interpreter. - * @param env - Python environment to compare against the active selection - * @returns True when the environment executable matches the current one - */ - private _isCurrent(env: PythonEnvironment): boolean { - if (!this._currentExecPath) { - return false; - } - return env.execInfo.run.executable === this._currentExecPath; - } - - /** - * Convert a manager identifier into a user-friendly tree label. - * @param managerId - Raw manager ID from the Python extension - * @returns Display name for the environment manager group - */ - private _getManagerDisplayName(managerId: string): string { - const parts = managerId.split(':'); - const name = parts[parts.length - 1] || managerId; - const lower = name.toLowerCase(); - - // Map known tool/type names to friendly labels - if (lower === 'system' || lower === 'unknown') { - return 'Global'; - } - if (lower === 'venv' || lower === 'virtualenv') { - return 'venv'; - } - if (lower === 'conda') { - return 'Conda'; - } - if (lower === 'pyenv') { - return 'Pyenv'; - } - if (lower === 'poetry') { - return 'Poetry'; - } - if (lower === 'pipenv') { - return 'Pipenv'; - } - - return name.charAt(0).toUpperCase() + name.slice(1); - } - - /** - * Whether the manager ID represents a virtual environment manager. - * @param managerId - Raw manager ID from the Python extension - * @returns True for venv-style managers - */ - private _isVenvManager(managerId: string): boolean { - const name = managerId.split(':').pop()?.toLowerCase() ?? ''; - return name === 'venv' || name === 'virtualenv' || name === 'virtualenvwrapper'; - } - - /** - * Whether the manager ID represents a global/system Python installation. - * @param managerId - Raw manager ID from the Python extension - * @returns True for global or unknown system managers - */ - private _isGlobalManager(managerId: string): boolean { - const name = managerId.split(':').pop()?.toLowerCase() ?? ''; - return name === 'system' || name === 'unknown'; - } - - /** - * Render a manager group or individual Python environment node. - * @param element - Tree node to display - * @returns Tree item with warnings for discouraged global environments - */ - getTreeItem(element: TreeNode): vscode.TreeItem { - if (element.type === 'manager') { - const isVenv = this._isVenvManager(element.id); - const isGlobal = this._isGlobalManager(element.id); - const isGlobalEnvSelected = - this._currentManagerId && this._isGlobalManager(this._currentManagerId); - - const item = new vscode.TreeItem( - element.name, - isVenv - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.Collapsed, - ); - - if (isGlobal && isGlobalEnvSelected) { - item.iconPath = new vscode.ThemeIcon( - 'globe', - new vscode.ThemeColor('problemsWarningIcon.foreground'), - ); - const tooltip = new vscode.MarkdownString( - '$(warning) **Global Python Environment Selected**\n\n' + - 'Use of global Python environments for Ansible development is strongly discouraged.\n\n' + - 'Please create and select a virtual environment instead.', - ); - tooltip.supportThemeIcons = true; - item.tooltip = tooltip; - item.description = '⚠️ not recommended'; - } else if (isGlobal) { - item.iconPath = new vscode.ThemeIcon('globe'); - } else { - item.iconPath = new vscode.ThemeIcon('folder'); - } - item.contextValue = 'envManager'; - return item; - } else { - const env = element.environment; - const isCurrent = this._isCurrent(env); - const isGlobalEnv = this._isGlobalManager(element.managerId); - - const item = new vscode.TreeItem( - env.displayName || env.name, - vscode.TreeItemCollapsibleState.None, - ); - if (isCurrent) { - item.iconPath = new vscode.ThemeIcon( - 'check', - new vscode.ThemeColor('charts.green'), - ); - } else if (isGlobalEnv) { - item.iconPath = new vscode.ThemeIcon( - 'symbol-misc', - new vscode.ThemeColor('problemsWarningIcon.foreground'), - ); - } else { - item.iconPath = new vscode.ThemeIcon('symbol-misc'); - } - item.contextValue = isCurrent ? 'pythonEnvironmentCurrent' : 'pythonEnvironment'; - - const tooltip = new vscode.MarkdownString(); - tooltip.supportThemeIcons = true; - if (isGlobalEnv) { - tooltip.appendMarkdown( - '$(warning) **Not recommended for Ansible development**\n\n', - ); - } - tooltip.appendMarkdown(`**${env.displayName}**\n\n`); - tooltip.appendMarkdown(`Version: ${env.version}\n\n`); - tooltip.appendMarkdown(`Path: ${env.sysPrefix || env.displayPath}`); - item.tooltip = tooltip; - - item.command = { - command: 'ansibleDevTools.selectEnvironment', - title: 'Select Environment', - arguments: [env], - }; - - return item; - } - } - - /** - * Return manager groups at the root or environments within a manager. - * @param element - Parent node whose children should be listed - * @returns Manager nodes or environment nodes for the requested level - */ - getChildren(element?: TreeNode): Thenable { - if (!element) { - const managers: ManagerNode[] = []; - for (const [managerId, environments] of this._managers) { - managers.push({ - type: 'manager', - id: managerId, - name: this._getManagerDisplayName(managerId), - environments, - }); - } - // Sort: venv first, then alphabetical, Global last - managers.sort((a, b) => { - const aIsVenv = this._isVenvManager(a.id); - const bIsVenv = this._isVenvManager(b.id); - const aIsGlobal = this._isGlobalManager(a.id); - const bIsGlobal = this._isGlobalManager(b.id); - if (aIsVenv !== bIsVenv) { - return aIsVenv ? -1 : 1; - } - if (aIsGlobal !== bIsGlobal) { - return aIsGlobal ? 1 : -1; - } - return a.name.localeCompare(b.name); - }); - return Promise.resolve(managers); - } else if (element.type === 'manager') { - const envNodes: EnvironmentNode[] = element.environments.map((env) => ({ - type: 'environment', - environment: env, - managerId: element.id, - })); - envNodes.sort((a, b) => - (a.environment.displayName || a.environment.name).localeCompare( - b.environment.displayName || b.environment.name, - ), - ); - return Promise.resolve(envNodes); - } - - return Promise.resolve([]); - } - - /** Release listeners and pending refresh timers. */ - dispose() { - if (this._refreshDebounce) { - clearTimeout(this._refreshDebounce); - } - this._envListener?.dispose(); - this._onDidChangeTreeData.dispose(); - } -} diff --git a/src/views/ExecutionEnvironmentsController.ts b/src/views/ExecutionEnvironmentsController.ts new file mode 100644 index 0000000000..905954f25d --- /dev/null +++ b/src/views/ExecutionEnvironmentsController.ts @@ -0,0 +1,37 @@ +import * as vscode from 'vscode'; +import { ExecutionEnvService } from '@ansible/developer-services'; +import { log } from '@src/extension'; + +/** Controller for local execution environment images and their details. */ +export class ExecutionEnvironmentsController { + private _onDidChange: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChange: vscode.Event = this._onDidChange.event; + + private _service: ExecutionEnvService; + private _serviceListener: vscode.Disposable | undefined; + + /** Create the controller and begin loading execution environments. */ + constructor() { + this._service = ExecutionEnvService.getInstance(); + this._service.setLogFunction(log); + + // Listen for service changes + this._serviceListener = (this._service.onDidChange as vscode.Event)(() => { + this._onDidChange.fire(undefined); + }); + + // Initial load + this.refresh(); + } + + /** Reload execution environment images and notify NavTree listeners. */ + refresh(): void { + void this._service.refresh(); + } + + /** Release service listeners and event emitters. */ + dispose(): void { + this._serviceListener?.dispose(); + this._onDidChange.dispose(); + } +} diff --git a/src/views/ExecutionEnvironmentsProvider.ts b/src/views/ExecutionEnvironmentsProvider.ts deleted file mode 100644 index 25b5465433..0000000000 --- a/src/views/ExecutionEnvironmentsProvider.ts +++ /dev/null @@ -1,370 +0,0 @@ -import * as vscode from 'vscode'; -import { ExecutionEnvService } from '@ansible/developer-services'; -import type { ExecutionEnvironment } from '@ansible/developer-services'; -import { log } from '@src/extension'; - -type TreeNode = EENode | EEDetailCategoryNode | EEDetailItemNode | MessageNode; - -/** Tree item that displays informational or actionable execution environment messages. */ -class MessageNode extends vscode.TreeItem { - /** - * Create a non-collapsible message node for the execution environments tree. - * @param label - Primary message text shown in the tree - * @param options - Optional presentation and command settings - * @param options.description - Secondary text shown inline in the tree - * @param options.tooltip - Hover text with additional detail - * @param options.icon - Theme icon name for the node - * @param options.command - VS Code command launched when the node is clicked - */ - constructor( - label: string, - options?: { - description?: string; - tooltip?: string; - icon?: string; - command?: vscode.Command; - }, - ) { - super(label, vscode.TreeItemCollapsibleState.None); - this.contextValue = 'eeMessage'; - if (options?.description) { - this.description = options.description; - } - if (options?.tooltip) { - this.tooltip = options.tooltip; - } - this.iconPath = new vscode.ThemeIcon(options?.icon ?? 'info'); - if (options?.command) { - this.command = options.command; - } - } -} - -/** Collapsible tree item representing a single execution environment image. */ -class EENode extends vscode.TreeItem { - /** - * Create a tree node for an execution environment summary. - * @param ee - Execution environment metadata from the container runtime - */ - constructor(public readonly ee: ExecutionEnvironment) { - super(ee.full_name, vscode.TreeItemCollapsibleState.Collapsed); - this.description = ee.created; - this.tooltip = new vscode.MarkdownString(); - this.tooltip.appendMarkdown(`**${ee.full_name}**\n\n`); - this.tooltip.appendMarkdown(`- Image ID: \`${ee.image_id}\`\n`); - this.tooltip.appendMarkdown(`- Created: ${ee.created}\n`); - this.iconPath = new vscode.ThemeIcon('package'); - this.contextValue = 'executionEnvironment'; - this.command = { - command: 'ansibleExecutionEnvironments.showDetail', - title: 'Show Details', - arguments: [ee.full_name], - }; - } -} - -/** Collapsible category node for execution environment detail sections. */ -class EEDetailCategoryNode extends vscode.TreeItem { - /** - * Create a detail category such as collections, packages, or info. - * @param label - Category label shown in the tree - * @param items - Detail items contained in this category - * @param eeName - Execution environment name that owns these details - */ - constructor( - public readonly label: string, - public readonly items: EEDetailItemNode[], - public readonly eeName: string, - ) { - super(label, vscode.TreeItemCollapsibleState.Collapsed); - this.description = `(${String(items.length)})`; - this.contextValue = 'eeDetailCategory'; - - // Set appropriate icon based on category - switch (label) { - case 'Ansible Collections': - this.iconPath = new vscode.ThemeIcon('library'); - break; - case 'Python Packages': - this.iconPath = new vscode.ThemeIcon('symbol-package'); - break; - case 'System Packages': - this.iconPath = new vscode.ThemeIcon('archive'); - break; - case 'Info': - this.iconPath = new vscode.ThemeIcon('info'); - break; - default: - this.iconPath = new vscode.ThemeIcon('list-tree'); - } - } -} - -/** Leaf tree item for a single execution environment detail entry. */ -class EEDetailItemNode extends vscode.TreeItem { - /** - * Create a detail item with optional description and tooltip text. - * @param label - Primary label shown in the tree - * @param options - Optional item configuration - * @param options.description - Secondary text shown inline - * @param options.tooltip - Hover text with additional detail - * @param options.eeName - EE image name for opening package detail - * @param options.packageType - Package category for opening detail views - */ - constructor( - label: string, - options?: { - description?: string; - tooltip?: string; - eeName?: string; - packageType?: 'python' | 'system'; - }, - ) { - super(label, vscode.TreeItemCollapsibleState.None); - this.description = options?.description; - if (options?.tooltip) { - this.tooltip = options.tooltip; - } - this.contextValue = 'eeDetailItem'; - - if (options?.eeName && options.packageType) { - this.command = { - command: 'ansibleExecutionEnvironments.showPackageDetail', - title: 'Show Package Details', - arguments: [options.eeName, label, options.packageType], - }; - } - } -} - -/** Tree view provider for local execution environment images and their details. */ -export class ExecutionEnvironmentsProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = - new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = - this._onDidChangeTreeData.event; - - private _service: ExecutionEnvService; - private _serviceListener: vscode.Disposable | undefined; - - /** Create the provider and begin loading execution environments. */ - constructor() { - this._service = ExecutionEnvService.getInstance(); - this._service.setLogFunction(log); - - // Listen for service changes - this._serviceListener = (this._service.onDidChange as vscode.Event)(() => { - this._onDidChangeTreeData.fire(undefined); - }); - - // Initial load - this.refresh(); - } - - /** Reload execution environment images and refresh the tree. */ - refresh(): void { - void this._service.refresh(); - } - - /** - * Return the tree item for an execution environment node. - * @param element - Node whose tree item should be displayed - * @returns The node itself because EE nodes extend TreeItem - */ - getTreeItem(element: TreeNode): vscode.TreeItem { - return element; - } - - /** - * Return root execution environments or the children of an EE node. - * @param element - Parent node whose children should be listed - * @returns Execution environments, detail categories, or detail items - */ - async getChildren(element?: TreeNode): Promise { - if (!element) { - // Root level - load execution environments - return this._getExecutionEnvironments(); - } - - if (element instanceof EENode) { - // Load EE details categories - return this._getEEDetailCategories(element.ee); - } - - if (element instanceof EEDetailCategoryNode) { - // Return the items in this category - return element.items; - } - - return []; - } - - /** - * Load execution environment images for the tree root. - * @returns EE nodes or status messages for loading, empty, and error states - */ - private async _getExecutionEnvironments(): Promise { - if (this._service.isLoading()) { - return [ - new MessageNode('Loading...', { - icon: 'sync~spin', - tooltip: 'Loading execution environments', - }), - ]; - } - - try { - const ees = await this._service.loadExecutionEnvironments(); - - if (ees.length === 0) { - return [ - new MessageNode('No execution environments found', { - description: 'Build or pull an EE image', - icon: 'info', - tooltip: 'Build or pull an execution environment image', - }), - ]; - } - - return ees.map((ee) => new EENode(ee)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - - // Check if ansible-navigator is not installed - if (message.includes('not found in PATH') || message.includes('not found')) { - return [ - new MessageNode('ansible-navigator not found', { - description: 'Click to install', - tooltip: 'Install ansible-dev-tools to enable Execution Environments', - icon: 'warning', - command: { - command: 'ansibleDevToolsPackages.install', - title: 'Install ansible-dev-tools', - }, - }), - ]; - } - - return [ - new MessageNode('Error loading execution environments', { - description: message, - icon: 'error', - tooltip: message, - }), - ]; - } - } - - /** - * Load detail categories for a selected execution environment. - * @param ee - Execution environment whose details should be shown - * @returns Detail categories or an error item when loading fails - */ - private async _getEEDetailCategories(ee: ExecutionEnvironment): Promise { - try { - const details = await this._service.loadDetails(ee.full_name); - - if (!details) { - return [new EEDetailItemNode('Failed to load details')]; - } - - const categories: TreeNode[] = []; - - // Info category - const infoItems: EEDetailItemNode[] = []; - if (details.ansible_version?.details) { - infoItems.push( - new EEDetailItemNode('Ansible', { - description: details.ansible_version.details, - }), - ); - } - const osDetails = details.os_release?.details; - if (osDetails?.[0]) { - const os = osDetails[0]; - const osName = os['pretty-name'] ?? os.name ?? 'Unknown'; - infoItems.push(new EEDetailItemNode('OS', { description: osName })); - } - if (details.image_name) { - infoItems.push(new EEDetailItemNode('Image', { description: details.image_name })); - } - if (infoItems.length > 0) { - categories.push(new EEDetailCategoryNode('Info', infoItems, ee.full_name)); - } - - // Ansible Collections category - if (details.ansible_collections?.details) { - const collectionItems: EEDetailItemNode[] = []; - const collections = Object.entries(details.ansible_collections.details).sort( - ([a], [b]) => a.localeCompare(b), - ); - - for (const [name, version] of collections) { - collectionItems.push(new EEDetailItemNode(name, { description: version })); - } - - if (collectionItems.length > 0) { - categories.push( - new EEDetailCategoryNode( - 'Ansible Collections', - collectionItems, - ee.full_name, - ), - ); - } - } - - // Python Packages category - if (details.python_packages?.details) { - const packageItems: EEDetailItemNode[] = []; - const packages = [...details.python_packages.details].sort((a, b) => - a.name.localeCompare(b.name), - ); - - for (const pkg of packages) { - packageItems.push( - new EEDetailItemNode(pkg.name, { - description: pkg.version, - tooltip: pkg.summary ?? undefined, - eeName: ee.full_name, - packageType: 'python', - }), - ); - } - - if (packageItems.length > 0) { - categories.push( - new EEDetailCategoryNode('Python Packages', packageItems, ee.full_name), - ); - } - } - - // System Packages category - const systemPkgs = await this._service.getSystemPackages(ee.full_name); - if (systemPkgs.length > 0) { - const systemItems = systemPkgs.map( - (pkg) => - new EEDetailItemNode(pkg.name, { - description: pkg.version, - eeName: ee.full_name, - packageType: 'system', - }), - ); - categories.push( - new EEDetailCategoryNode('System Packages', systemItems, ee.full_name), - ); - } - - return categories; - } catch (error) { - log( - `ExecutionEnvironmentsProvider: Failed to load EE details: ${error instanceof Error ? error.message : String(error)}`, - ); - return [ - new EEDetailItemNode('Error loading details', { - tooltip: String(error), - }), - ]; - } - } -} diff --git a/src/views/McpToolsController.ts b/src/views/McpToolsController.ts new file mode 100644 index 0000000000..6a31e8d4b5 --- /dev/null +++ b/src/views/McpToolsController.ts @@ -0,0 +1,170 @@ +/** + * MCP Tools controller + * + * Loads available MCP tools and provides example prompts for chat injection. + */ + +import * as vscode from 'vscode'; +import { STATIC_TOOLS, type McpToolDefinition, CreatorToolGenerator } from '@ansible/mcp-server'; +import { CreatorService, buildMcpToolExamplePrompt } from '@ansible/developer-services'; +import { log } from '@src/extension'; +import { openChatWithPrompt } from '@src/features/chatProvider'; + +type ToolCategory = + 'getting_started' | 'discovery' | 'generation' | 'execution' | 'devtools' | 'creator'; + +export interface ToolInfo { + tool: McpToolDefinition; + category: ToolCategory; + examplePrompt: string; +} + +/** NavTree data source for Ansible MCP tools and example chat prompts. */ +export class McpToolsController { + private _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; + + private _tools: ToolInfo[] = []; + private _creatorToolGenerator: CreatorToolGenerator; + private _isLoading = false; + private _creatorServiceListener: vscode.Disposable | undefined; + + /** + * Create the controller and load static and creator MCP tools. + * @param _context - Extension context (reserved for future MCP host wiring) + */ + constructor(_context: vscode.ExtensionContext) { + void _context; + this._creatorToolGenerator = new CreatorToolGenerator(); + + // Listen for CreatorService changes to auto-refresh when schema loads + const creatorService = CreatorService.getInstance(); + this._creatorServiceListener = (creatorService.onDidChange as vscode.Event)(() => { + if (creatorService.isLoaded()) { + log('McpToolsController: CreatorService loaded, refreshing tools...'); + void this._loadTools(); + } + }); + + void this._loadTools(); + } + + /** Release creator service listeners and event emitters. */ + dispose(): void { + this._creatorServiceListener?.dispose(); + this._onDidChange.dispose(); + } + + /** Reload MCP tool definitions and notify NavTree listeners. */ + refresh(): void { + void this._loadTools(); + } + + /** Load static and creator-generated MCP tools into the model. */ + private async _loadTools(): Promise { + if (this._isLoading) { + return; + } + + this._isLoading = true; + this._tools = []; + + try { + // Load static tools with categories + for (const tool of STATIC_TOOLS) { + const category = this._categorizeStaticTool(tool.name); + const examplePrompt = this._generateExamplePrompt(tool); + this._tools.push({ tool, category, examplePrompt }); + } + + // Load dynamic creator tools + try { + log('McpToolsController: Loading creator tools...'); + await this._creatorToolGenerator.refresh(); + const creatorTools = this._creatorToolGenerator.getTools(); + log( + `McpToolsController: CreatorToolGenerator returned ${String(creatorTools.length)} tools`, + ); + + for (const tool of creatorTools) { + const examplePrompt = this._generateExamplePrompt(tool); + this._tools.push({ tool, category: 'creator', examplePrompt }); + } + + log( + `McpToolsController: Loaded ${String(STATIC_TOOLS.length)} static + ${String(creatorTools.length)} creator tools`, + ); + } catch (error) { + log( + `McpToolsController: Failed to load creator tools: ${error instanceof Error ? error.message : String(error)}`, + ); + // Continue with static tools only + } + } finally { + this._isLoading = false; + this._onDidChange.fire(undefined); + } + } + + /** + * Assign a display category to a static MCP tool by name. + * @param name - MCP tool identifier + * @returns Category used to group the tool in the NavTree + */ + private _categorizeStaticTool(name: string): ToolCategory { + if (name === 'get_agent_onboarding' || name === 'get_extension_walkthrough') { + return 'getting_started'; + } + if ( + name.includes('search') || + name.includes('list') || + name.includes('get_plugin_documentation') + ) { + return 'discovery'; + } + if (name.includes('generate') || name.includes('build_ansible_task')) { + return 'generation'; + } + if (name.includes('execution_environment') || name.includes('ee_')) { + return 'execution'; + } + if (name.includes('dev_tools')) { + return 'devtools'; + } + return 'discovery'; // default + } + + /** + * Build an example chat prompt that instructs an agent to use the tool. + * @param tool - MCP tool definition to describe in the prompt + * @returns Example prompt suitable for injection into chat + */ + private _generateExamplePrompt(tool: McpToolDefinition): string { + return buildMcpToolExamplePrompt(tool.name, tool.description); + } + + /** + * Get the CreatorToolGenerator instance for MCP handlers + * @returns Shared generator used to build dynamic creator MCP tools + */ + getCreatorToolGenerator(): CreatorToolGenerator { + return this._creatorToolGenerator; + } + + /** + * Get all loaded tools + * @returns Copy of the currently loaded MCP tool metadata + */ + getAllTools(): ToolInfo[] { + return [...this._tools]; + } +} + +/** + * Inject a tool prompt into the chat + * @param toolInfo - Tool metadata containing the example prompt to send + */ +export async function injectToolPromptIntoChat(toolInfo: ToolInfo): Promise { + await openChatWithPrompt(toolInfo.examplePrompt); + log(`McpToolsController: Opened chat with prompt`); +} diff --git a/src/views/McpToolsProvider.ts b/src/views/McpToolsProvider.ts deleted file mode 100644 index 51c14b60f3..0000000000 --- a/src/views/McpToolsProvider.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * MCP Tools Tree View Provider - * - * Displays available MCP tools and allows users to inject prompts - * into Cursor/Copilot chat to invoke them. - */ - -import * as vscode from 'vscode'; -import { STATIC_TOOLS, type McpToolDefinition, CreatorToolGenerator } from '@ansible/mcp-server'; -import { CreatorService, buildMcpToolExamplePrompt } from '@ansible/developer-services'; -import { log } from '@src/extension'; -import { getMcpStatus, McpStatus } from '@src/mcp/cursorConfig'; -import { openChatWithPrompt } from '@src/features/chatProvider'; - -type ToolCategory = - 'getting_started' | 'discovery' | 'generation' | 'execution' | 'devtools' | 'creator'; - -export interface ToolInfo { - tool: McpToolDefinition; - category: ToolCategory; - examplePrompt: string; -} - -/** Collapsible tree item representing a category of MCP tools. */ -class ToolCategoryNode extends vscode.TreeItem { - /** - * Create a category node with a tool count description. - * @param categoryLabel - Display label for the category - * @param categoryId - Internal category identifier - * @param toolCount - Number of tools contained in the category - */ - constructor( - public readonly categoryLabel: string, - public readonly categoryId: ToolCategory, - public readonly toolCount: number, - ) { - super(categoryLabel, vscode.TreeItemCollapsibleState.Expanded); - this.contextValue = 'toolCategory'; - this.description = `${String(toolCount)} tools`; - - // Set icons based on category - const iconMap: Record = { - getting_started: 'rocket', - discovery: 'search', - generation: 'code', - execution: 'package', - devtools: 'tools', - creator: 'wand', - }; - this.iconPath = new vscode.ThemeIcon(iconMap[categoryId]); - } -} - -/** Leaf tree item for a single MCP tool with chat injection command. */ -class ToolNode extends vscode.TreeItem { - public readonly sortKey: string; - - /** - * Create a tool node from MCP tool metadata and an example prompt. - * @param toolInfo - Tool definition, category, and example chat prompt - */ - constructor(public readonly toolInfo: ToolInfo) { - // Use the first line of description as the label - const firstLine = toolInfo.tool.description.split('\n')[0].trim(); - const label = firstLine.length > 60 ? firstLine.substring(0, 57) + '...' : firstLine; - super(label, vscode.TreeItemCollapsibleState.None); - - // Store lowercase for sorting - this.sortKey = firstLine.toLowerCase(); - - this.tooltip = new vscode.MarkdownString(this._formatTooltip()); - this.contextValue = 'mcpTool'; - this.iconPath = new vscode.ThemeIcon('sparkle'); - - // Command to inject prompt into chat - this.command = { - command: 'ansibleMcpTools.useInChat', - title: 'Use in Chat', - arguments: [toolInfo], - }; - } - - /** - * Build markdown tooltip content describing the tool and its parameters. - * @returns Markdown tooltip text for the tool node - */ - private _formatTooltip(): string { - const tool = this.toolInfo.tool; - const lines: string[] = [`### ${tool.name}`, '', tool.description, '']; - - // Show parameters - const props = tool.inputSchema.properties; - const required = new Set(tool.inputSchema.required ?? []); - - if (Object.keys(props).length > 0) { - lines.push('**Parameters:**'); - for (const [name, schema] of Object.entries(props)) { - const s = schema as { description?: string; type?: string }; - const reqMark = required.has(name) ? ' *(required)*' : ''; - lines.push(`- \`${name}\`${reqMark}: ${s.description ?? s.type ?? 'any'}`); - } - lines.push(''); - } - - lines.push('**Example prompt:**'); - lines.push(`\`${this.toolInfo.examplePrompt}\``); - - return lines.join('\n'); - } -} - -/** Warning node shown when MCP is not configured for the current IDE. */ -class McpWarningNode extends vscode.TreeItem { - /** - * Create a warning node that links to MCP configuration. - * @param status - Current MCP status for the active IDE - */ - constructor(status: McpStatus) { - const ideNames: Record = { - bob: 'IBM Bob', - cursor: 'Cursor', - vscode: 'VS Code Copilot', - }; - const ideName = ideNames[status.ide] ?? status.ide; - super(`MCP not configured for ${ideName}`, vscode.TreeItemCollapsibleState.None); - - this.description = 'click to configure'; - this.iconPath = new vscode.ThemeIcon( - 'warning', - new vscode.ThemeColor('problemsWarningIcon.foreground'), - ); - this.tooltip = `AI tools require MCP to be configured.\n\nClick to configure for ${ideName}.`; - this.contextValue = 'mcpWarning'; - this.command = { - command: 'ansibleMcpTools.configure', - title: 'Configure MCP', - }; - } -} - -type ToolTreeItem = ToolCategoryNode | ToolNode | McpWarningNode; - -/** Tree view provider for Ansible MCP tools and example chat prompts. */ -export class McpToolsProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - private _tools: ToolInfo[] = []; - private _creatorToolGenerator: CreatorToolGenerator; - private _isLoading = false; - private _creatorServiceListener: vscode.Disposable | undefined; - private _extensionContext: vscode.ExtensionContext; - - /** - * Create the provider and load static and creator MCP tools. - * @param context - Extension context used to evaluate MCP configuration status - */ - constructor(context: vscode.ExtensionContext) { - this._extensionContext = context; - this._creatorToolGenerator = new CreatorToolGenerator(); - - // Listen for CreatorService changes to auto-refresh when schema loads - const creatorService = CreatorService.getInstance(); - this._creatorServiceListener = (creatorService.onDidChange as vscode.Event)(() => { - if (creatorService.isLoaded()) { - log('McpToolsProvider: CreatorService loaded, refreshing tools...'); - void this._loadTools(); - } - }); - - void this._loadTools(); - } - - /** Release creator service listeners. */ - dispose(): void { - this._creatorServiceListener?.dispose(); - } - - /** Reload MCP tool definitions and refresh the tree. */ - refresh(): void { - void this._loadTools(); - } - - /** Load static and creator-generated MCP tools into the tree model. */ - private async _loadTools(): Promise { - if (this._isLoading) { - return; - } - - this._isLoading = true; - this._tools = []; - - try { - // Load static tools with categories - for (const tool of STATIC_TOOLS) { - const category = this._categorizeStaticTool(tool.name); - const examplePrompt = this._generateExamplePrompt(tool); - this._tools.push({ tool, category, examplePrompt }); - } - - // Load dynamic creator tools - try { - log('McpToolsProvider: Loading creator tools...'); - await this._creatorToolGenerator.refresh(); - const creatorTools = this._creatorToolGenerator.getTools(); - log( - `McpToolsProvider: CreatorToolGenerator returned ${String(creatorTools.length)} tools`, - ); - - for (const tool of creatorTools) { - const examplePrompt = this._generateExamplePrompt(tool); - this._tools.push({ tool, category: 'creator', examplePrompt }); - } - - log( - `McpToolsProvider: Loaded ${String(STATIC_TOOLS.length)} static + ${String(creatorTools.length)} creator tools`, - ); - } catch (error) { - log( - `McpToolsProvider: Failed to load creator tools: ${error instanceof Error ? error.message : String(error)}`, - ); - // Continue with static tools only - } - } finally { - this._isLoading = false; - this._onDidChangeTreeData.fire(undefined); - } - } - - /** - * Assign a display category to a static MCP tool by name. - * @param name - MCP tool identifier - * @returns Category used to group the tool in the tree - */ - private _categorizeStaticTool(name: string): ToolCategory { - if (name === 'get_agent_onboarding' || name === 'get_extension_walkthrough') { - return 'getting_started'; - } - if ( - name.includes('search') || - name.includes('list') || - name.includes('get_plugin_documentation') - ) { - return 'discovery'; - } - if (name.includes('generate') || name.includes('build_ansible_task')) { - return 'generation'; - } - if (name.includes('execution_environment') || name.includes('ee_')) { - return 'execution'; - } - if (name.includes('dev_tools')) { - return 'devtools'; - } - return 'discovery'; // default - } - - /** - * Build an example chat prompt that instructs an agent to use the tool. - * @param tool - MCP tool definition to describe in the prompt - * @returns Example prompt suitable for injection into chat - */ - private _generateExamplePrompt(tool: McpToolDefinition): string { - return buildMcpToolExamplePrompt(tool.name, tool.description); - } - - /** - * Return the tree item for an MCP tool tree node. - * @param element - Node whose tree item should be displayed - * @returns The node itself because MCP tool nodes extend TreeItem - */ - getTreeItem(element: ToolTreeItem): vscode.TreeItem { - return element; - } - - /** - * Return MCP warning, categories, or tools within a selected category. - * @param element - Parent node whose children should be listed - * @returns Root categories, category tools, or an empty list - */ - getChildren(element?: ToolTreeItem): ToolTreeItem[] | Promise { - if (!element) { - const items: ToolTreeItem[] = []; - - // Check MCP status and show warning if not configured - const status = getMcpStatus(this._extensionContext); - if (!status.isConfigured) { - items.push(new McpWarningNode(status)); - } - - // Show tool categories - const categories: { id: ToolCategory; label: string }[] = [ - { id: 'getting_started', label: 'Getting Started' }, - { id: 'discovery', label: 'Discovery' }, - { id: 'generation', label: 'Task Generation' }, - { id: 'execution', label: 'Execution Environments' }, - { id: 'devtools', label: 'Dev Tools' }, - { id: 'creator', label: 'Creator' }, - ]; - - const categoryNodes = categories - .map((cat) => { - const count = this._tools.filter((t) => t.category === cat.id).length; - return new ToolCategoryNode(cat.label, cat.id, count); - }) - .filter((node) => node.toolCount > 0); - - items.push(...categoryNodes); - return items; - } - - if (element instanceof ToolCategoryNode) { - // Return tools in this category, sorted by description - const nodes = this._tools - .filter((t) => t.category === element.categoryId) - .map((t) => new ToolNode(t)); - // Sort alphabetically by description - nodes.sort((a, b) => a.sortKey.localeCompare(b.sortKey)); - return nodes; - } - - return []; - } - - /** - * Get the CreatorToolGenerator instance for MCP handlers - * @returns Shared generator used to build dynamic creator MCP tools - */ - getCreatorToolGenerator(): CreatorToolGenerator { - return this._creatorToolGenerator; - } - - /** - * Get all loaded tools - * @returns Copy of the currently loaded MCP tool metadata - */ - getAllTools(): ToolInfo[] { - return [...this._tools]; - } -} - -/** - * Inject a tool prompt into the chat - * @param toolInfo - Tool metadata containing the example prompt to send - */ -export async function injectToolPromptIntoChat(toolInfo: ToolInfo): Promise { - await openChatWithPrompt(toolInfo.examplePrompt); - log(`McpToolsProvider: Opened chat with prompt`); -} diff --git a/src/views/PlaybooksController.ts b/src/views/PlaybooksController.ts new file mode 100644 index 0000000000..9d0da7d753 --- /dev/null +++ b/src/views/PlaybooksController.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; +import { PlaybooksService } from '@src/services/PlaybooksService'; +import { log } from '@src/extension'; + +/** Controller for workspace playbooks and their plays. */ +export class PlaybooksController { + private _service: PlaybooksService; + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; + private _serviceListener: vscode.Disposable | undefined; + + /** Create the controller and trigger an initial playbook discovery pass. */ + constructor() { + this._service = PlaybooksService.getInstance(); + + // Listen for service changes + this._serviceListener = this._service.onDidChange(() => { + this._onDidChange.fire(undefined); + }); + + // Initial load + log('PlaybooksController: Triggering initial refresh'); + void this._service.refresh(); + } + + /** Reload playbooks from the workspace and notify NavTree listeners. */ + public refresh(): void { + void this._service.refresh(); + } + + /** Release service listeners and event emitters. */ + dispose(): void { + this._serviceListener?.dispose(); + this._onDidChange.dispose(); + } +} diff --git a/src/views/PlaybooksProvider.ts b/src/views/PlaybooksProvider.ts deleted file mode 100644 index 959ccff9d6..0000000000 --- a/src/views/PlaybooksProvider.ts +++ /dev/null @@ -1,307 +0,0 @@ -import * as vscode from 'vscode'; -import * as path from 'path'; -import { PlaybooksService, PlaybookInfo, PlaybookPlay } from '@src/services/PlaybooksService'; -import { log } from '@src/extension'; - -type TreeNode = WorkspaceFolderNode | FolderNode | PlaybookNode | PlayNode | LoadingNode; - -/** Tree node representing a workspace folder in a multi-root workspace. */ -class WorkspaceFolderNode { - /** - * Create a workspace folder node for multi-root playbook browsing. - * @param folder - Workspace folder whose playbooks should be shown - */ - constructor(public readonly folder: vscode.WorkspaceFolder) {} -} - -/** Tree node representing a subdirectory that contains playbooks. */ -class FolderNode { - /** - * Create a folder node within a workspace playbook hierarchy. - * @param name - Folder name shown in the tree - * @param fullPath - Absolute path to the folder on disk - * @param workspaceFolder - Workspace folder that owns this path - */ - constructor( - public readonly name: string, - public readonly fullPath: string, - public readonly workspaceFolder: vscode.WorkspaceFolder, - ) {} -} - -/** Tree node representing a discovered Ansible playbook file. */ -class PlaybookNode { - /** - * Create a playbook node from discovered playbook metadata. - * @param playbook - Parsed playbook information from the workspace scan - */ - constructor(public readonly playbook: PlaybookInfo) {} -} - -/** Tree node representing a single play within a playbook. */ -class PlayNode { - /** - * Create a play node linked back to its parent playbook. - * @param play - Parsed play metadata from the playbook - * @param playbook - Parent playbook containing the play - */ - constructor( - public readonly play: PlaybookPlay, - public readonly playbook: PlaybookInfo, - ) {} -} - -/** Placeholder tree node shown while playbooks are loading or absent. */ -class LoadingNode { - /** - * Create a loading or empty-state node with a status message. - * @param message - Status text shown in the tree - */ - constructor(public readonly message: string) {} -} - -/** Tree view provider for workspace playbooks and their plays. */ -export class PlaybooksProvider implements vscode.TreeDataProvider { - private _service: PlaybooksService; - private readonly _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - /** Create the provider and trigger an initial playbook discovery pass. */ - constructor() { - this._service = PlaybooksService.getInstance(); - - // Listen for service changes - this._service.onDidChange(() => { - this._onDidChangeTreeData.fire(undefined); - }); - - // Initial load - log('PlaybooksProvider: Triggering initial refresh'); - void this._service.refresh(); - } - - /** Reload playbooks from the workspace and refresh the tree. */ - public refresh(): void { - void this._service.refresh(); - } - - /** - * Render a workspace folder, folder, playbook, play, or loading node. - * @param element - Tree node to display - * @returns Tree item with icons, tooltips, and navigation commands - */ - getTreeItem(element: TreeNode): vscode.TreeItem { - if (element instanceof LoadingNode) { - const item = new vscode.TreeItem(element.message); - item.iconPath = new vscode.ThemeIcon('sync~spin'); - return item; - } - - if (element instanceof WorkspaceFolderNode) { - const item = new vscode.TreeItem( - element.folder.name, - vscode.TreeItemCollapsibleState.Expanded, - ); - item.iconPath = new vscode.ThemeIcon('root-folder'); - item.contextValue = 'workspaceFolder'; - item.tooltip = element.folder.uri.fsPath; - return item; - } - - if (element instanceof FolderNode) { - const item = new vscode.TreeItem( - element.name, - vscode.TreeItemCollapsibleState.Collapsed, - ); - item.iconPath = new vscode.ThemeIcon('folder'); - item.contextValue = 'folder'; - item.tooltip = element.fullPath; - return item; - } - - if (element instanceof PlaybookNode) { - const playbook = element.playbook; - const item = new vscode.TreeItem( - path.basename(playbook.path), - vscode.TreeItemCollapsibleState.Collapsed, - ); - - item.iconPath = new vscode.ThemeIcon('notebook'); - item.description = `${String(playbook.plays.length)} play${playbook.plays.length !== 1 ? 's' : ''}`; - item.contextValue = 'playbook'; - - // Tooltip with path and details - const tooltip = new vscode.MarkdownString(); - tooltip.appendMarkdown(`**${playbook.name}**\n\n`); - tooltip.appendMarkdown(`📂 \`${playbook.path}\`\n\n`); - tooltip.appendMarkdown(`**Plays:**\n`); - for (const play of playbook.plays) { - tooltip.appendMarkdown(`- ${play.name} (hosts: \`${play.hosts}\`)\n`); - } - item.tooltip = tooltip; - - // Store playbook info for commands - item.command = { - command: 'ansiblePlaybooks.openPlaybook', - title: 'Open Playbook', - arguments: [playbook], - }; - - return item; - } - - if (element instanceof PlayNode) { - const play = element.play; - const item = new vscode.TreeItem(play.name, vscode.TreeItemCollapsibleState.None); - - item.iconPath = new vscode.ThemeIcon('target'); - item.description = `hosts: ${play.hosts}`; - item.contextValue = 'play'; - - // Tooltip - const tooltip = new vscode.MarkdownString(); - tooltip.appendMarkdown(`**Play:** ${play.name}\n\n`); - tooltip.appendMarkdown(`**Hosts:** \`${play.hosts}\`\n\n`); - tooltip.appendMarkdown(`**Line:** ${String(play.lineNumber)}`); - item.tooltip = tooltip; - - // Click to go to line - item.command = { - command: 'ansiblePlaybooks.goToPlay', - title: 'Go to Play', - arguments: [element.playbook, play], - }; - - return item; - } - - return new vscode.TreeItem('Unknown'); - } - - /** - * Return workspace roots, folder contents, plays, or loading placeholders. - * @param element - Parent node whose children should be listed - * @returns Child nodes for the requested tree level - */ - getChildren(element?: TreeNode): TreeNode[] | Promise { - if (!element) { - // Root level - if (this._service.isLoading()) { - return [new LoadingNode('Discovering playbooks...')]; - } - - const playbooks = this._service.getPlaybooks(); - - if (playbooks.length === 0) { - if (this._service.isLoaded()) { - return [new LoadingNode('No playbooks found')]; - } - return [new LoadingNode('Discovering playbooks...')]; - } - - const workspaceFolders = vscode.workspace.workspaceFolders ?? []; - - // Single workspace - show folder hierarchy directly - if (workspaceFolders.length === 1) { - return this._buildFolderHierarchy(playbooks, workspaceFolders[0]); - } - - // Multi-root - show workspace folders as top level - return workspaceFolders.map((folder) => new WorkspaceFolderNode(folder)); - } - - if (element instanceof WorkspaceFolderNode) { - // Show folder hierarchy for this workspace - const allPlaybooks = this._service.getPlaybooks(); - const folderPath = element.folder.uri.fsPath; - const folderPlaybooks = allPlaybooks.filter((pb) => pb.path.startsWith(folderPath)); - return this._buildFolderHierarchy(folderPlaybooks, element.folder); - } - - if (element instanceof FolderNode) { - // Show contents of this folder - const allPlaybooks = this._service.getPlaybooks(); - const folderPlaybooks = allPlaybooks.filter((pb) => - pb.path.startsWith(element.fullPath), - ); - return this._buildFolderContents( - folderPlaybooks, - element.fullPath, - element.workspaceFolder, - ); - } - - if (element instanceof PlaybookNode) { - // Show plays for this playbook - return element.playbook.plays.map((play) => new PlayNode(play, element.playbook)); - } - - return []; - } - - /** - * Build the folder and playbook hierarchy for a workspace root. - * @param playbooks - Playbooks discovered within the workspace - * @param workspaceFolder - Workspace folder used as the hierarchy root - * @returns Top-level folder and playbook nodes for the workspace - */ - private _buildFolderHierarchy( - playbooks: PlaybookInfo[], - workspaceFolder: vscode.WorkspaceFolder, - ): TreeNode[] { - const rootPath = workspaceFolder.uri.fsPath; - return this._buildFolderContents(playbooks, rootPath, workspaceFolder); - } - - /** - * Build child folder and playbook nodes for a specific directory. - * @param playbooks - Playbooks that live under the target folder - * @param folderPath - Absolute path of the folder being expanded - * @param workspaceFolder - Workspace folder that owns the path - * @returns Sorted subfolder and playbook nodes for the directory - */ - private _buildFolderContents( - playbooks: PlaybookInfo[], - folderPath: string, - workspaceFolder: vscode.WorkspaceFolder, - ): TreeNode[] { - const nodes: TreeNode[] = []; - const subfolders = new Map(); - const directPlaybooks: PlaybookInfo[] = []; - - for (const playbook of playbooks) { - const relativePath = path.relative(folderPath, playbook.path); - const parts = relativePath.split(path.sep); - - if (parts.length === 1) { - // Playbook is directly in this folder - directPlaybooks.push(playbook); - } else { - // Playbook is in a subfolder - const subfolderName = parts[0]; - if (!subfolders.has(subfolderName)) { - subfolders.set(subfolderName, []); - } - subfolders.get(subfolderName)?.push(playbook); - } - } - - // Add subfolders first (sorted) - const sortedFolders = Array.from(subfolders.keys()).sort(); - for (const folderName of sortedFolders) { - nodes.push( - new FolderNode(folderName, path.join(folderPath, folderName), workspaceFolder), - ); - } - - // Add playbooks (sorted by name) - const sortedPlaybooks = directPlaybooks.sort((a, b) => - path.basename(a.path).localeCompare(path.basename(b.path)), - ); - for (const playbook of sortedPlaybooks) { - nodes.push(new PlaybookNode(playbook)); - } - - return nodes; - } -} diff --git a/src/views/SkillsController.ts b/src/views/SkillsController.ts new file mode 100644 index 0000000000..4a6447b458 --- /dev/null +++ b/src/views/SkillsController.ts @@ -0,0 +1,103 @@ +/** + * Skills controller + * + * Loads AI development skills from configured sources for the sidebar NavTree. + */ + +import * as vscode from 'vscode'; +import { + SkillRegistry, + buildSkillLoadPrompt, + buildSkillClipboardPrompt, +} from '@ansible/developer-services'; +import type { SkillEntry } from '@ansible/developer-services'; +import { openChatWithPrompt } from '@src/features/chatProvider'; +import { log } from '@src/extension'; + +/** NavTree data source for configured Ansible AI skills. */ +export class SkillsController { + private _onDidChange = new vscode.EventEmitter(); + + readonly onDidChange = this._onDidChange.event; + + private _registry: SkillRegistry; + + /** Initializes the controller with a shared SkillRegistry instance. */ + constructor() { + this._registry = SkillRegistry.getInstance(); + } + + /** Trigger a full reload of all skill sources. */ + refresh(): void { + this._onDidChange.fire(undefined); + + this._registry + .refresh() + .then(() => { + this._onDidChange.fire(undefined); + this._notifyEmptySources(); + }) + .catch((err: unknown) => { + log(`SkillsController: refresh failed: ${String(err)}`); + this._onDidChange.fire(undefined); + }); + } + + /** Release event emitters owned by this controller. */ + dispose(): void { + this._onDidChange.dispose(); + } + + /** + * Show an informational notification for user-added sources that loaded + * zero skills. Default sources (like ai-forge) are silently hidden from + * the NavTree and do not trigger a notification. + */ + private _notifyEmptySources(): void { + const sources = this._registry.getSources(); + const allSkills = this._registry.getAllSkills(); + const defaultSourceIds = new Set(['builtin', 'ai-forge']); + + for (const source of sources) { + if (defaultSourceIds.has(source.id)) continue; + const count = allSkills.filter((s) => s.source === source.id).length; + if (count === 0) { + void vscode.window + .showInformationMessage( + `Skill source "${source.id}" loaded 0 skills. The URL may not point to a supported skill repository.`, + 'Open Settings', + ) + .then((choice) => { + if (choice === 'Open Settings') { + void vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'ansibleEnvironments.skillSources', + ); + } + }); + } + } + } +} + +/** + * Open the AI chat with a lightweight prompt that directs the agent + * to fetch the skill via MCP rather than injecting the full content. + * + * @param skill - The skill entry to use. + */ +export async function openChatWithSkill(skill: SkillEntry): Promise { + const prompt = buildSkillLoadPrompt(skill.name, skill.id, skill.description); + await openChatWithPrompt(prompt); +} + +/** + * Copy a skill prompt to the clipboard. + * + * @param skill - The skill entry to copy the prompt for. + */ +export async function copySkillPrompt(skill: SkillEntry): Promise { + const prompt = buildSkillClipboardPrompt(skill.name, skill.id, skill.description); + await vscode.env.clipboard.writeText(prompt); + void vscode.window.showInformationMessage(`Prompt for "${skill.name}" copied to clipboard.`); +} diff --git a/src/views/SkillsProvider.ts b/src/views/SkillsProvider.ts deleted file mode 100644 index 71591cc44f..0000000000 --- a/src/views/SkillsProvider.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Skills Tree View Provider - * - * Displays AI development skills from configured sources, grouped by - * source and module. Provides "Use in Chat" and "Copy Prompt" actions. - */ - -import * as vscode from 'vscode'; -import { - SkillRegistry, - buildSkillLoadPrompt, - buildSkillClipboardPrompt, -} from '@ansible/developer-services'; -import type { SkillEntry, SkillSource } from '@ansible/developer-services'; -import { openChatWithPrompt } from '@src/features/chatProvider'; -import { log } from '@src/extension'; - -type TreeNode = SourceNode | ModuleNode | SkillNode | MessageNode; - -/** Static tree item showing an informational or warning message. */ -class MessageNode extends vscode.TreeItem { - /** - * @param label - Text to display. - * @param icon - Optional codicon name (defaults to "info"). - */ - constructor(label: string, icon?: string) { - super(label, vscode.TreeItemCollapsibleState.None); - this.contextValue = 'skillMessage'; - this.iconPath = new vscode.ThemeIcon(icon ?? 'info'); - } -} - -/** Tree item representing a configured skill source. */ -class SourceNode extends vscode.TreeItem { - /** - * @param source - Skill source metadata. - * @param skillCount - Number of skills loaded from this source. - */ - constructor( - public readonly source: SkillSource, - public readonly skillCount: number, - ) { - super(source.id, vscode.TreeItemCollapsibleState.Collapsed); - this.contextValue = 'skillSource'; - this.description = - skillCount > 0 - ? `${source.trust} · ${String(skillCount)} skills` - : `${source.trust} · no skills loaded`; - this.tooltip = new vscode.MarkdownString( - `**${source.id}**\n\n` + - `- Type: ${source.type}\n` + - `- Trust: ${source.trust}\n` + - `- URL: ${source.url}\n` + - `- Skills: ${String(skillCount)}`, - ); - - const iconMap: Record = { - community: 'globe', - certified: 'verified', - partner: 'organization', - private: 'lock', - }; - this.iconPath = new vscode.ThemeIcon(iconMap[source.trust] ?? 'globe'); - } -} - -/** Tree item representing a module grouping within a source. */ -class ModuleNode extends vscode.TreeItem { - /** - * @param moduleName - Module display name. - * @param sourceId - Parent source ID. - * @param skillCount - Number of skills in this module. - */ - constructor( - public readonly moduleName: string, - public readonly sourceId: string, - public readonly skillCount: number, - ) { - super(moduleName, vscode.TreeItemCollapsibleState.Collapsed); - this.contextValue = 'skillModule'; - this.description = `${String(skillCount)} skills`; - this.iconPath = new vscode.ThemeIcon('package'); - } -} - -/** Leaf tree item representing a single skill. */ -class SkillNode extends vscode.TreeItem { - /** @param skill - The indexed skill entry. */ - constructor(public readonly skill: SkillEntry) { - super(skill.name, vscode.TreeItemCollapsibleState.None); - this.contextValue = 'skill'; - this.description = skill.category; - this.tooltip = new vscode.MarkdownString( - `**${skill.name}**\n\n` + - `${skill.description}\n\n` + - `- Source: ${skill.source} (${skill.trust})\n` + - `- Module: ${skill.module}\n` + - `- Category: ${skill.category}\n` + - (skill.domain ? `- Domain: ${skill.domain}\n` : '') + - (skill.triggers.length > 0 ? `- Triggers: ${skill.triggers.join(', ')}\n` : '') + - `\n\`skill_get({ skill_id: "${skill.id}" })\``, - ); - this.iconPath = new vscode.ThemeIcon('mortar-board'); - - this.command = { - command: 'ansibleSkills.useInChat', - title: 'Use in Chat', - arguments: [skill], - }; - } -} - -/** Tree data provider for the Ansible Skills sidebar view. */ -export class SkillsProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData = new vscode.EventEmitter(); - - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - private _registry: SkillRegistry; - private _loading = false; - - /** Initializes the provider with a shared SkillRegistry instance. */ - constructor() { - this._registry = SkillRegistry.getInstance(); - } - - /** Trigger a full reload of all skill sources. */ - refresh(): void { - this._loading = true; - this._onDidChangeTreeData.fire(undefined); - - this._registry - .refresh() - .then(() => { - this._loading = false; - this._onDidChangeTreeData.fire(undefined); - this._notifyEmptySources(); - }) - .catch((err: unknown) => { - log(`SkillsProvider: refresh failed: ${String(err)}`); - this._loading = false; - this._onDidChangeTreeData.fire(undefined); - }); - } - - /** @inheritdoc */ - getTreeItem(element: TreeNode): vscode.TreeItem { - return element; - } - - /** @inheritdoc */ - async getChildren(element?: TreeNode): Promise { - if (this._loading) { - return [new MessageNode('Loading skills...', 'loading~spin')]; - } - - // Root level: list sources - if (!element) { - await this._registry.ensureLoaded(); - const sources = this._registry.getSources(); - const allSkills = this._registry.getAllSkills(); - - if (sources.length === 0) { - return [new MessageNode('No skill sources configured', 'warning')]; - } - - if (allSkills.length === 0) { - return [new MessageNode('No skills available', 'info')]; - } - - return sources.map((source) => { - const count = allSkills.filter((s) => s.source === source.id).length; - return new SourceNode(source, count); - }); - } - - // Source level: list modules (or skills directly when only one module) - if (element instanceof SourceNode) { - const skills = this._registry - .getAllSkills() - .filter((s) => s.source === element.source.id); - - if (skills.length === 0) { - const url = element.source.url; - const hint = url - ? `Browse available skills at ${url}` - : 'Configure access in Settings.'; - return [new MessageNode(`No skills loaded. ${hint}`, 'info')]; - } - - const modules = new Map(); - for (const skill of skills) { - const existing = modules.get(skill.module); - if (existing) { - existing.push(skill); - } else { - modules.set(skill.module, [skill]); - } - } - - if (modules.size === 1) { - const [, modSkills] = [...modules.entries()][0]; - return modSkills.map((s) => new SkillNode(s)); - } - - return [...modules.entries()].map( - ([mod, modSkills]) => new ModuleNode(mod, element.source.id, modSkills.length), - ); - } - - // Module level: list skills - if (element instanceof ModuleNode) { - const skills = this._registry - .getAllSkills() - .filter((s) => s.source === element.sourceId && s.module === element.moduleName); - return skills.map((s) => new SkillNode(s)); - } - - return []; - } - - /** - * Show an informational notification for user-added sources that loaded - * zero skills. Default sources (like ai-forge) are silently hidden from - * the tree and do not trigger a notification. - */ - private _notifyEmptySources(): void { - const sources = this._registry.getSources(); - const allSkills = this._registry.getAllSkills(); - const defaultSourceIds = new Set(['builtin', 'ai-forge']); - - for (const source of sources) { - if (defaultSourceIds.has(source.id)) continue; - const count = allSkills.filter((s) => s.source === source.id).length; - if (count === 0) { - void vscode.window - .showInformationMessage( - `Skill source "${source.id}" loaded 0 skills. The URL may not point to a supported skill repository.`, - 'Open Settings', - ) - .then((choice) => { - if (choice === 'Open Settings') { - void vscode.commands.executeCommand( - 'workbench.action.openSettings', - 'ansibleEnvironments.skillSources', - ); - } - }); - } - } - } -} - -/** - * Open the AI chat with a lightweight prompt that directs the agent - * to fetch the skill via MCP rather than injecting the full content. - * - * @param skill - The skill entry to use. - */ -export async function openChatWithSkill(skill: SkillEntry): Promise { - const prompt = buildSkillLoadPrompt(skill.name, skill.id, skill.description); - await openChatWithPrompt(prompt); -} - -/** - * Copy a skill prompt to the clipboard. - * - * @param skill - The skill entry to copy the prompt for. - */ -export async function copySkillPrompt(skill: SkillEntry): Promise { - const prompt = buildSkillClipboardPrompt(skill.name, skill.id, skill.description); - await vscode.env.clipboard.writeText(prompt); - void vscode.window.showInformationMessage(`Prompt for "${skill.name}" copied to clipboard.`); -} diff --git a/test/integration/activation.test.ts b/test/integration/activation.test.ts index 6e5a9a8925..54810c1dfe 100644 --- a/test/integration/activation.test.ts +++ b/test/integration/activation.test.ts @@ -8,7 +8,7 @@ interface ExtensionManifest { viewsContainers?: { activitybar?: { id: string }[]; }; - views?: Record; + views?: Record; languageModelTools?: { name: string }[]; }; } @@ -39,6 +39,7 @@ suite('Ansible Extension', () => { 'ansibleDevToolsCollections', 'ansibleCreator', 'ansiblePlaybooks', + 'ansible.sidebar.navTree', ]; for (const prefix of expectedPrefixes) { @@ -58,27 +59,19 @@ suite('Ansible Extension', () => { assert.ok(container, 'Should contribute ansible-environments view container'); }); - test('contributes expected tree views', () => { + test('contributes only Ansible NavTree webview (no native trees)', () => { const ext = vscode.extensions.getExtension(EXTENSION_ID); const pkg = ext?.packageJSON as ExtensionManifest | undefined; const views = pkg?.contributes?.views?.['ansible-environments']; assert.ok(views, 'Should contribute views under ansible-environments'); - - const expectedViewIds = [ - 'ansibleDevToolsEnvManagers', - 'ansibleDevToolsPackages', - 'ansibleDevToolsCollections', - 'ansibleCollectionSources', - 'ansibleExecutionEnvironments', - 'ansibleCreator', - 'ansiblePlaybooks', - ]; - - for (const viewId of expectedViewIds) { - const found = views.some((v: { id: string }) => v.id === viewId); - assert.ok(found, `Should contribute view "${viewId}"`); - } + assert.strictEqual(views.length, 1, 'Should contribute exactly one view'); + assert.strictEqual(views[0]?.id, 'ansibleNavTree'); + assert.strictEqual( + views[0]?.when, + undefined, + 'NavTree should not be gated by a when clause', + ); }); test('contributes extension settings', () => { diff --git a/test/integration/no-envs/activation.test.ts b/test/integration/no-envs/activation.test.ts index 6564706dfe..07997a6d94 100644 --- a/test/integration/no-envs/activation.test.ts +++ b/test/integration/no-envs/activation.test.ts @@ -1,6 +1,6 @@ /** * Integration test that validates extension activation and degraded-mode - * behavior when ms-python.vscode-python-envs is NOT installed. + * behavior when ms-python.vscode-python-envs is NOT available. * * Run via: pnpm test:integration:no-envs */ @@ -18,12 +18,21 @@ suite('Ansible Extension — without python-envs', () => { } }); - test('python-envs extension is NOT installed in this profile', () => { + test('python-envs extension is not active in this profile', () => { const envsExt = vscode.extensions.getExtension(PYTHON_ENVS_ID); - assert.strictEqual( - envsExt, - undefined, - `${PYTHON_ENVS_ID} should not be installed in the no-python-envs test profile`, + // Profile may still have the extension on disk (shared install cache from + // the default suite) but launches with --disable-extension. + assert.ok( + !envsExt?.isActive, + `${PYTHON_ENVS_ID} should not be active in the no-python-envs test profile`, + ); + // ADR-019 python-only path: ms-python.python remains available for discovery. + const pythonExt = vscode.extensions.getExtension('ms-python.python'); + assert.ok(pythonExt, 'ms-python.python should be available for python-only degraded mode'); + const ansible = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok( + ansible?.isActive, + 'Ansible should activate in degraded mode without python-envs', ); }); @@ -34,6 +43,15 @@ suite('Ansible Extension — without python-envs', () => { }); test('registers expected commands even without python-envs', async () => { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, 'Extension should be installed'); + assert.ok(ext.isActive, 'Extension should be active before checking commands'); + + const contributed = ( + (ext.packageJSON as { contributes?: { commands?: { command: string }[] } }).contributes + ?.commands ?? [] + ).map((c) => c.command); + const commands = await vscode.commands.getCommands(true); const expectedPrefixes = [ @@ -44,26 +62,38 @@ suite('Ansible Extension — without python-envs', () => { ]; for (const prefix of expectedPrefixes) { + const inPackageJson = contributed.some((cmd) => cmd.startsWith(prefix)); + assert.ok( + inPackageJson, + `package.json should contribute commands with prefix "${prefix}"`, + ); const found = commands.some((cmd) => cmd.startsWith(prefix)); - assert.ok(found, `Should register commands with prefix "${prefix}"`); + assert.ok( + found, + `Should register commands with prefix "${prefix}" (active=${String(ext.isActive)}, sample=${commands + .filter( + (c) => + c.startsWith('ansible') || + c.startsWith('ansibleDevTools') || + c.startsWith('ansibleCreator') || + c.startsWith('ansiblePlaybooks'), + ) + .slice(0, 20) + .join(', ')})`, + ); } }); - test('contributes tree views regardless of python-envs presence', () => { + test('contributes Ansible NavTree webview regardless of python-envs presence', () => { const ext = vscode.extensions.getExtension(EXTENSION_ID); const pkg = ext?.packageJSON as { - contributes?: { views?: Record }; + contributes?: { views?: Record }; }; const views = pkg.contributes?.views?.['ansible-environments']; assert.ok(views, 'Should contribute views under ansible-environments'); - - const expectedViewIds = ['ansibleDevToolsEnvManagers', 'ansibleDevToolsPackages']; - - for (const viewId of expectedViewIds) { - const found = views.some((v: { id: string }) => v.id === viewId); - assert.ok(found, `Should contribute view "${viewId}"`); - } + assert.strictEqual(views.length, 1); + assert.strictEqual(views[0]?.id, 'ansibleNavTree'); }); test('extension remains active after attempting refresh', async () => { diff --git a/test/ui/navTreeSmoke.spec.ts b/test/ui/navTreeSmoke.spec.ts new file mode 100644 index 0000000000..23bc8f7c42 --- /dev/null +++ b/test/ui/navTreeSmoke.spec.ts @@ -0,0 +1,83 @@ +import { browser } from '@wdio/globals'; +import type * as VsCode from 'vscode'; + +interface NavTreeWebview { + open(): Promise; + close(): Promise; +} + +/** + * @covers XC-001 + */ +describe('Ansible NavTree smoke', () => { + it('should focus Ansible NavTree and open one accordion section', async () => { + await browser.executeWorkbench(async (vscode: typeof VsCode) => { + await vscode.commands.executeCommand('ansible.sidebar.navTree.open'); + }); + + const workbench = await browser.getWorkbench(); + let navTree: NavTreeWebview | undefined; + + await browser.waitUntil( + async () => { + const webviews = await workbench.getAllWebviews(); + for (const candidate of webviews) { + await candidate.open(); + try { + const shell = await $('.ansible-sidebar-shell'); + if (await shell.isExisting()) { + navTree = candidate; + return true; + } + } catch { + // Not the NavTree webview — try the next one. + } + await candidate.close(); + } + return false; + }, + { + timeout: 30_000, + interval: 500, + timeoutMsg: 'Ansible NavTree webview did not appear', + }, + ); + + if (!navTree) { + throw new Error('Ansible NavTree webview missing after wait'); + } + + // Already switched into the webview frame during waitUntil — calling + // open() again looks for #active-frame from inside the frame and times out. + try { + const shell = await $('.ansible-sidebar-shell'); + await shell.waitForExist({ timeout: 30_000 }); + + const toggle = await $('#sidebar-section-envManagers'); + await toggle.waitForExist({ timeout: 10_000 }); + + // Issue-driven suggestOpen may already open a section — normalize closed, then open. + if ((await toggle.getAttribute('aria-expanded')) === 'true') { + await toggle.click(); + await browser.waitUntil( + async () => (await toggle.getAttribute('aria-expanded')) === 'false', + { timeout: 5_000, timeoutMsg: 'Could not collapse Environment Managers' }, + ); + } + + await toggle.click(); + await browser.waitUntil( + async () => (await toggle.getAttribute('aria-expanded')) === 'true', + { + timeout: 5_000, + timeoutMsg: 'Environment Managers section did not expand', + }, + ); + + const panel = await $('#sidebar-panel-envManagers'); + expect(await panel.isExisting()).toBe(true); + } finally { + await navTree.close(); + } + }); +}); diff --git a/test/ui/noAiFallback.spec.ts b/test/ui/noAiFallback.spec.ts index 486acfc768..6006a76af6 100644 --- a/test/ui/noAiFallback.spec.ts +++ b/test/ui/noAiFallback.spec.ts @@ -1,17 +1,7 @@ import { browser } from '@wdio/globals'; import type * as VsCode from 'vscode'; -const CORE_VIEWS = [ - 'ansibleDevToolsEnvManagers', - 'ansibleDevToolsPackages', - 'ansibleDevToolsCollections', - 'ansibleCollectionSources', - 'ansibleExecutionEnvironments', - 'ansibleCreator', - 'ansiblePlaybooks', -]; - -const AI_GATED_VIEWS = ['ansibleMcpTools', 'ansibleSkills']; +const NAV_TREE_VIEW = 'ansibleNavTree'; /** * @covers XC-002 @@ -32,54 +22,39 @@ describe('Graceful degradation without AI', () => { }); }); - it('should register all core tree views when AI is disabled', async () => { - const contributed: string[] = await browser.executeWorkbench( - (vscode: typeof VsCode, viewIds: string[]) => { - const ext = vscode.extensions.getExtension('redhat.ansible'); - if (!ext) return []; - const pkg = ext.packageJSON as { - contributes?: { views?: Record }; - }; - const allViews = Object.values(pkg.contributes?.views ?? {}).flat(); - const ids = new Set(allViews.map((v) => v.id)); - return viewIds.filter((id) => ids.has(id)); - }, - CORE_VIEWS, - ); + it('should contribute only Ansible NavTree when AI is disabled', async () => { + const viewIds: string[] = await browser.executeWorkbench((vscode: typeof VsCode) => { + const ext = vscode.extensions.getExtension('redhat.ansible'); + if (!ext) return []; + const pkg = ext.packageJSON as { + contributes?: { views?: Record }; + }; + return Object.values(pkg.contributes?.views ?? {}) + .flat() + .map((v) => v.id); + }); - expect(contributed).toEqual(CORE_VIEWS); + expect(viewIds).toEqual([NAV_TREE_VIEW]); }); - it('should hide AI-gated views when enableAiFeatures is false', async () => { + it('should keep AI commands available when enableAiFeatures is false', async () => { const aiSetting: boolean = await browser.executeWorkbench((vscode: typeof VsCode) => { return vscode.workspace .getConfiguration('ansibleEnvironments') .get('enableAiFeatures', true); }); - expect(aiSetting).toBe(false); - // AI Tools and AI Skills views have a "when" clause that hides them. - // Verify the when clause is present in the manifest. - const gatedCorrectly: boolean = await browser.executeWorkbench( - (vscode: typeof VsCode, viewIds: string[]) => { - const ext = vscode.extensions.getExtension('redhat.ansible'); - if (!ext) return false; - const pkg = ext.packageJSON as { - contributes?: { - views?: Record; - }; - }; - const allViews = Object.values(pkg.contributes?.views ?? {}).flat(); - return viewIds.every((id) => { - const view = allViews.find((v) => v.id === id); - return view?.when?.includes('enableAiFeatures') ?? false; - }); + const hasAiCommands: boolean = await browser.executeWorkbench( + async (vscode: typeof VsCode) => { + const commands = await vscode.commands.getCommands(true); + return ( + commands.includes('ansibleMcpTools.refresh') && + commands.includes('ansibleSkills.refresh') + ); }, - AI_GATED_VIEWS, ); - - expect(gatedCorrectly).toBe(true); + expect(hasAiCommands).toBe(true); }); it('should keep the extension active and error-free', async () => { diff --git a/test/unit/sidebar/navTreeHostHelpers.test.ts b/test/unit/sidebar/navTreeHostHelpers.test.ts new file mode 100644 index 0000000000..1a5138596d --- /dev/null +++ b/test/unit/sidebar/navTreeHostHelpers.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { preserveExpandedChildren } from '@ansible/developer-services'; +import type { SidebarSnapshot, SidebarTreeNode } from '@ansible/common'; +import { + extractSelectEnvironmentId, + isPlaybookCommand, + normalizePlaybookPayload, +} from '@src/sidebar/navTreeCommandArgs'; +import { buildSidebarNavTreeHtml } from '@src/sidebar/navTreeHtml'; +import { MCP_CATEGORY_LABELS, mcpIdeDisplayName } from '@src/sidebar/mcpLabels'; + +/** + * Minimal tree node for preserveExpandedChildren fixtures. + * @param partial - Node fields (id/label required) + * @returns Sidebar tree node + */ +function node(partial: Partial & { id: string; label: string }): SidebarTreeNode { + return partial; +} + +/** + * Single-section snapshot fixture. + * @param nodes - Nodes under the Collections section + * @returns Sidebar snapshot + */ +function snap(nodes: SidebarTreeNode[]): SidebarSnapshot { + return { + sections: [{ id: 'collections', title: 'Collections', nodes }], + }; +} + +describe('preserveExpandedChildren', () => { + it('returns fresh when there is no previous snapshot', () => { + const fresh = snap([node({ id: 'a', label: 'A', lazyChildren: true })]); + expect(preserveExpandedChildren(undefined, fresh)).toBe(fresh); + }); + + it('restores expanded children onto matching lazy nodes', () => { + const children = [node({ id: 'c1', label: 'Child' })]; + const previous = snap([ + node({ + id: 'col', + label: 'ns.name', + expand: { + kind: 'galaxyCollection', + namespace: 'ns', + name: 'name', + version: '1.0.0', + }, + children, + }), + ]); + const fresh = snap([ + node({ + id: 'col', + label: 'ns.name', + lazyChildren: true, + expand: { + kind: 'galaxyCollection', + namespace: 'ns', + name: 'name', + version: '1.0.0', + }, + }), + ]); + + const next = preserveExpandedChildren(previous, fresh); + const restored = next.sections[0].nodes[0]; + expect(restored.lazyChildren).toBe(false); + expect(restored.children).toEqual(children); + // Original fresh node stays lazy + expect(fresh.sections[0].nodes[0].lazyChildren).toBe(true); + }); + + it('skips restore when previous had no expanded expand-nodes', () => { + const previous = snap([ + node({ id: 'x', label: 'X', children: [node({ id: 'y', label: 'Y' })] }), + ]); + const fresh = snap([node({ id: 'x', label: 'X', lazyChildren: true })]); + expect(preserveExpandedChildren(previous, fresh)).toBe(fresh); + }); +}); + +describe('navTreeCommandArgs', () => { + it('detects playbook commands', () => { + expect(isPlaybookCommand('ansiblePlaybooks.run')).toBe(true); + expect(isPlaybookCommand('ansibleDevTools.refresh')).toBe(false); + }); + + it('normalizes legacy pb- path strings', () => { + const payload = normalizePlaybookPayload(['pb-/tmp/site.yml']); + expect(payload?.playbook.path).toBe('/tmp/site.yml'); + expect(payload?.playbook.name).toBe('site.yml'); + }); + + it('passes through structured playbook payloads', () => { + const payload = normalizePlaybookPayload([ + { + playbook: { + path: '/ws/play.yml', + name: 'play.yml', + relativePath: 'play.yml', + plays: [], + }, + }, + ]); + expect(payload?.playbook.path).toBe('/ws/play.yml'); + }); + + it('returns undefined for unrelated args', () => { + expect(normalizePlaybookPayload(['hello'])).toBeUndefined(); + expect(normalizePlaybookPayload([])).toBeUndefined(); + }); + + it('extracts selectEnvironment envId', () => { + expect(extractSelectEnvironmentId([{ envId: 'env-1' }])).toBe('env-1'); + expect(extractSelectEnvironmentId([{}])).toBeUndefined(); + expect(extractSelectEnvironmentId([])).toBeUndefined(); + }); +}); + +describe('mcpLabels', () => { + it('maps known IDE ids and falls back for unknown', () => { + expect(mcpIdeDisplayName('cursor')).toBe('Cursor'); + expect(mcpIdeDisplayName('bob')).toBe('IBM Bob'); + expect(mcpIdeDisplayName('other')).toBe('other'); + }); + + it('exposes category labels used by assembleSidebarInput', () => { + expect(MCP_CATEGORY_LABELS.creator).toBe('Creator'); + expect(MCP_CATEGORY_LABELS.discovery).toBe('Discovery'); + }); +}); + +describe('buildSidebarNavTreeHtml', () => { + it('embeds CSP, root marker, and script nonce', () => { + const html = buildSidebarNavTreeHtml({ + scriptUri: 'https://example/webview.js', + codiconsUri: 'https://example/codicon.css', + nonce: 'abc123', + cspSource: 'https://csp.source', + }); + expect(html).toContain("script-src 'nonce-abc123'"); + expect(html).toContain('data-view="sidebar-navtree"'); + expect(html).toContain('nonce="abc123"'); + expect(html).toContain('src="https://example/webview.js"'); + expect(html).toContain('href="https://example/codicon.css"'); + }); +}); diff --git a/test/unit/sidebar/sidebarSnapshotPatch.test.ts b/test/unit/sidebar/sidebarSnapshotPatch.test.ts new file mode 100644 index 0000000000..993cbe39d9 --- /dev/null +++ b/test/unit/sidebar/sidebarSnapshotPatch.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { SidebarModel } from '@ansible/developer-services'; + +/** + * Host-facing expand helpers live on SidebarModel (pure); exercise the + * patch path the webview host uses after lazy expand. + */ +describe('sidebar expand helpers (host seam)', () => { + const model = new SidebarModel(); + + it('patches nested children without mutating the original snapshot', () => { + const snap = model.buildSnapshot({ + pythonAvailable: true, + enableAiFeatures: false, + envManagers: [], + devTools: [{ name: 'ansible-lint', version: '1' }], + hasDevTools: true, + collections: [], + playbooks: [], + executionEnvironments: [ + { + created: 't', + execution_environment: true, + full_name: 'ee:latest', + image_id: 'id', + }, + ], + }); + const ee = snap.sections.find((s) => s.id === 'executionEnvironments')?.nodes[0]; + expect(ee?.lazyChildren).toBe(true); + if (!ee) { + return; + } + + const next = model.patchNodeChildren(snap, ee.id, [ + { id: 'info', label: 'Info', icon: 'info' }, + ]); + expect( + snap.sections.find((s) => s.id === 'executionEnvironments')?.nodes[0]?.children, + ).toBe(undefined); + expect( + next.sections.find((s) => s.id === 'executionEnvironments')?.nodes[0]?.children?.[0] + ?.label, + ).toBe('Info'); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 4ca680f5ad..6caf7b642a 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -60,7 +60,7 @@ export default defineConfig({ project('services', 'packages/services', ['test/**/*.test.ts']), project('mcp', 'packages/mcp-server', ['test/**/*.test.ts']), project('ls', 'packages/language-server', ['test/**/*.test.ts']), - project('ui', 'packages/ui', ['test/**/*.test.ts']), + project('ui', 'packages/ui', ['test/**/*.test.ts', 'test/**/*.test.tsx']), project('lightspeed', 'packages/lightspeed', ['test/**/*.test.ts']), { resolve: {